From a18ad0a30c386d05df9160ff9277e85b13da1763 Mon Sep 17 00:00:00 2001 From: Jinyi Shi Date: Mon, 17 Aug 2026 10:49:24 +1000 Subject: [PATCH] feat: auto-inject deterministic dedup key in api-request.sh for create endpoints Generate a SHA-256 hash from the canonical request fingerprint (base URL, uppercase method, resolved path, canonical JSON body) as the Idempotency-Key. Uses canonicaljson library via uv for JSON normalization, with json.dumps and shasum fallbacks. Same request always produces the same key. No client-side cache or state. Add --no-dedup-key flag for opt-out. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/api-request.sh | 31 ++++- scripts/canonical-hash.py | 32 +++++ .../references/create-retry-safety.md | 38 ++++++ tests/test-scenarios.md | 115 ++++++++++++++++++ 4 files changed, 210 insertions(+), 6 deletions(-) create mode 100755 scripts/canonical-hash.py create mode 100644 skills/api-reference/references/create-retry-safety.md diff --git a/scripts/api-request.sh b/scripts/api-request.sh index 4a7b2e6..3c0502b 100755 --- a/scripts/api-request.sh +++ b/scripts/api-request.sh @@ -105,16 +105,24 @@ if [ "${1:-}" = "--env" ] || [ "${2:-}" = "--env" ]; then fi # --- Parse arguments --- -if [ $# -lt 3 ]; then - echo "Usage: api-request.sh [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 [--no-dedup-key] [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}" @@ -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") diff --git a/scripts/canonical-hash.py b/scripts/canonical-hash.py new file mode 100755 index 0000000..97dcc97 --- /dev/null +++ b/scripts/canonical-hash.py @@ -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()) diff --git a/skills/api-reference/references/create-retry-safety.md b/skills/api-reference/references/create-retry-safety.md new file mode 100644 index 0000000..fc5cebe --- /dev/null +++ b/skills/api-reference/references/create-retry-safety.md @@ -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` diff --git a/tests/test-scenarios.md b/tests/test-scenarios.md index e9ad21c..d93c033 100644 --- a/tests/test-scenarios.md +++ b/tests/test-scenarios.md @@ -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: "` +- 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