Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions scripts/api-request.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,24 @@ if [ "${1:-}" = "--env" ] || [ "${2:-}" = "--env" ]; then
fi

# --- Parse arguments ---
if [ $# -lt 3 ]; then
echo "Usage: api-request.sh <skill> <METHOD> <path> [json_body]" >&2
NO_DEDUP_KEY=false
SKILL="$1"
shift

if [ "${1:-}" = "--no-dedup-key" ]; then
NO_DEDUP_KEY=true
shift
fi

if [ $# -lt 2 ]; then
echo "Usage: api-request.sh <skill> [--no-dedup-key] <METHOD> <path> [json_body]" >&2
echo " api-request.sh --env" >&2
exit 1
fi

SKILL="$1"
METHOD="$2"
PATH_ARG="$3"
BODY="${4:-}"
METHOD="$1"
PATH_ARG="$2"
BODY="${3:-}"

SKILL_HEADER="X-Spotify-Ads-Skill: ${SKILL}"

Expand All @@ -130,6 +138,17 @@ CURL_ARGS+=(-H "Authorization: Bearer ${TOKEN}")
CURL_ARGS+=(-H "$SDK_HEADER")
CURL_ARGS+=(-H "$SKILL_HEADER")

# --- Auto-inject dedup key for supported create endpoints ---
if [ "$METHOD" = "POST" ] && [ "$NO_DEDUP_KEY" = "false" ]; then
RESOLVED_PATH="${PATH_ARG%%\?*}"
if printf '%s' "$RESOLVED_PATH" | grep -qE "^ad_accounts/[^/]+/(drafts/)?(campaigns|ad_sets|ads)$"; then
DEDUP_KEY=$(python3 "$SCRIPT_DIR/canonical-hash.py" "$BODY" "$BASE_URL" "$RESOLVED_PATH" "$METHOD" 2>/dev/null \
|| uv run "$SCRIPT_DIR/canonical-hash.py" "$BODY" "$BASE_URL" "$RESOLVED_PATH" "$METHOD" 2>/dev/null \
|| printf '%s' "${BASE_URL}|${METHOD}|${RESOLVED_PATH}|${BODY}" | shasum -a 256 | cut -d' ' -f1)
CURL_ARGS+=(-H "Idempotency-Key: ${DEDUP_KEY}")
fi
fi

if [ -n "$BODY" ]; then
CURL_ARGS+=(-H "Content-Type: application/json")
CURL_ARGS+=(-d "$BODY")
Expand Down
32 changes: 32 additions & 0 deletions scripts/canonical-hash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
# /// script
# dependencies = ["canonicaljson"]
# ///
"""Generate a deterministic dedup key from a request fingerprint."""
import hashlib
import json
import sys

try:
import canonicaljson

HAS_CANONICAL = True
except ImportError:
HAS_CANONICAL = False

body = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else ""
base_url = sys.argv[2]
path = sys.argv[3]
method = sys.argv[4].upper()

try:
parsed = json.loads(body)
if HAS_CANONICAL:
canonical = canonicaljson.encode_canonical_json(parsed).decode("utf-8")
else:
canonical = json.dumps(parsed, sort_keys=True, separators=(",", ":"))
except (json.JSONDecodeError, ValueError):
canonical = body

fingerprint = base_url + "|" + method + "|" + path + "|" + canonical
print(hashlib.sha256(fingerprint.encode()).hexdigest())
38 changes: 38 additions & 0 deletions skills/api-reference/references/create-retry-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Create Request Retry Safety

The API request wrapper automatically generates a deterministic key for POST requests on supported create endpoints. This key prevents duplicate entity creation when a request is retried after a timeout or lost response. No user action is required — the protection is on by default.

## How It Works

- The wrapper builds a fingerprint from the base URL, HTTP method, resolved path, and canonical JSON body (keys sorted, whitespace stripped)
- The fingerprint is hashed with SHA-256 — the hash is the key
- The same request always produces the same key, so retries are automatically detected by the server
- Different requests (different body, different path) produce different keys and succeed independently

## Opt Out

To disable automatic key generation for a specific request, use the `--no-dedup-key` flag:

```bash
api --no-dedup-key POST "ad_accounts/{ad_account_id}/campaigns" '{"name":"..."}'
```

## Error Handling

| Response | Action |
|----------|--------|
| Network timeout / lost response | Retry the exact same request — the wrapper produces the same key, so the server detects the duplicate |
| `409` with `error_code: IDEMPOTENCY_REQUEST_IN_PROGRESS` | The original request is still being processed. Honor the `Retry-After` header and wait before retrying |
| `409` with `error_code: IDEMPOTENCY_REQUEST_ALREADY_COMPLETED` | The entity was already created. Use the `resource_uri` from the response to GET the resource and continue |
| `409` with `error_code: IDEMPOTENCY_KEY_REUSED` | A different request body was sent with a key that was already used. Do not retry — start a new request |
| `409` with `error_code: IDEMPOTENCY_OUTCOME_INDETERMINATE` | The previous request's outcome is uncertain. List recent entities to check if it was created, and ask the user how to proceed. Do not auto-retry |
| Validation error (4xx) | Fix the request body and retry — the new body produces a new key automatically |

## Supported Endpoints

- `POST /ad_accounts/{id}/campaigns`
- `POST /ad_accounts/{id}/ad_sets`
- `POST /ad_accounts/{id}/ads`
- `POST /ad_accounts/{id}/drafts/campaigns`
- `POST /ad_accounts/{id}/drafts/ad_sets`
- `POST /ad_accounts/{id}/drafts/ads`
115 changes: 115 additions & 0 deletions tests/test-scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -1191,3 +1191,118 @@ rules, effective PATCH validation, minimal user interruption, and draft validati
- PATCH validation uses current entity + patch + required parent context
- Clone validation follows the destination campaign product
- Draft hierarchy validation and explicit publish confirmation remain intact

---

## Scenario 36: Create Request Dedup Key — Deterministic Hash

**Prompt:** "Create a draft campaign called 'Dedup Test Campaign'"

**Quirks tested:** Wrapper generates a deterministic key from the request fingerprint

**Expected behavior:**
1. Skill calls `api POST "ad_accounts/{ad_account_id}/drafts/campaigns" '{"name":"Dedup Test Campaign"}'` — no manual header
2. The wrapper builds a fingerprint from base URL, method, path, and canonical JSON body
3. The fingerprint is hashed with SHA-256 — the hash is the key
4. Campaign is created successfully (HTTP 200)

**Success criteria:**
- The curl command includes `-H "Idempotency-Key: <sha256-hash>"`
- The skill file does NOT manually add the header
- Running the same request again produces the same key

---

## Scenario 37: Create Request Dedup Key — Same Request Same Key

**Prompt:** Retry the same draft campaign creation after a timeout

**Quirks tested:** Identical requests produce the same deterministic key

**Expected behavior:**
1. First call: `api POST "ad_accounts/{id}/drafts/campaigns" '{"name":"Test"}'` → key = sha256(fingerprint)
2. Retry (same body): same fingerprint → same key
3. Server detects duplicate key → returns original result

**Success criteria:**
- Both calls produce the identical `Idempotency-Key` value
- No duplicate entity created

---

## Scenario 38: Create Request Dedup Key — Different Body Different Key

**Prompt:** "Build a draft campaign with one ad set and one ad"

**Quirks tested:** Different request bodies produce different keys

**Expected behavior:**
1. Three separate `api POST` calls — campaign, ad set, ad
2. Each has a different body → different fingerprint → different key
3. All three entities created successfully

**Success criteria:**
- Three different `Idempotency-Key` values
- Parent IDs passed correctly between steps

---

## Scenario 39: Create Request Dedup Key — JSON Key Order Ignored

**Prompt:** Create a campaign with fields in different order

**Quirks tested:** Canonical JSON normalization produces the same key regardless of field order

**Expected behavior:**
1. `'{"name":"Test","objective":"REACH"}'` and `'{"objective":"REACH","name":"Test"}'` produce the same key
2. The wrapper sorts JSON keys before hashing

**Success criteria:**
- Both field orderings produce the identical `Idempotency-Key` value

---

## Scenario 40: Create Request Dedup Key — Non-Create POST Excluded

**Prompt:** "Validate my draft campaign" or "Publish my draft campaign"

**Quirks tested:** Dedup key not injected for POST endpoints that are not entity creation

**Expected behavior:**
1. Validate/publish calls `api POST "ad_accounts/{ad_account_id}/drafts/campaigns/{id}"` (includes an entity ID in path)
2. The wrapper does NOT match this against the create endpoint patterns
3. No `Idempotency-Key` header added

**Success criteria:**
- No `Idempotency-Key` header on non-create POST requests

---

## Scenario 41: Create Request Dedup Key — Opt-Out Flag

**Prompt:** Advanced user explicitly disabling dedup key

**Quirks tested:** `--no-dedup-key` flag prevents key injection

**Expected behavior:**
1. Skill calls `api --no-dedup-key POST "ad_accounts/{ad_account_id}/campaigns" '{"name":"No Key"}'`
2. The wrapper skips key generation
3. Request sent without `Idempotency-Key` header

**Success criteria:**
- No `Idempotency-Key` header when `--no-dedup-key` is used

---

## Scenario 42: Create Request Dedup Key — GET and PATCH Excluded

**Prompt:** "List my campaigns" or "Update campaign name"

**Quirks tested:** Dedup key only injected for POST, not GET or PATCH

**Expected behavior:**
1. GET and PATCH requests pass through the wrapper without key injection
2. No `Idempotency-Key` header added

**Success criteria:**
- No `Idempotency-Key` header on GET or PATCH requests