DEV Community

Dakota Liu
Dakota Liu

Posted on

Idempotency Keys Are a Product Contract: A Case Study of an Agent-Written Export Job API

You should treat idempotency keys as a product contract, not as an implementation detail an agent can improvise. A side-effecting POST that creates export jobs will duplicate work whenever clients retry after a timeout. This case study walks a small export-job API from frozen rules through tests, a handler sketch, and explicit limits. This walkthrough is a labeled worked example rather than a production postmortem from a named company.

Background: a tiny export job API

You are adding POST /export-jobs so a client can request a CSV export of recent orders. The client will retry after network failures, and your gateway may also retry on 502 responses. If the handler is not idempotent, each retry creates another job, another worker, and another email. Coding agents are especially likely to skip this, because the happy path looks complete without replay tests.

Industry tooling now makes it cheap to generate handlers, clients, and even tool wrappers around the same route. That speed helps only when the conflict behavior is already written down in tests the agent cannot ignore. You do not need a new model announcement to see the failure; you need a frozen replay contract. The rest of this article stays on one endpoint so the rules stay specific and reviewable.

Goal

You will freeze three decisions in writing before any generated handler code touches the database. Those decisions are key lifetime, request fingerprinting, and the exact status code for a conflicting replay. The artifact is a decision table plus a pytest file an agent must satisfy before it writes the route. You should not let the model invent REST folklore while you review the pull request after the fact.

Success for this case study means a retry cannot create a second job when the fingerprint matches. Success also means a reused key with a different body is rejected without silently mutating the first job. You are not trying to invent distributed exactly-once delivery across regions, queues, and email providers. You are trying to make local POST replay boring, testable, and safe for an agent to implement.

Frozen contract

Write these rules in the repository, not in a chat transcript the next session will forget.

Header and storage rules

  • Clients must send an Idempotency-Key header that contains one to one hundred twenty-eight printable ASCII characters.
  • You store the key, a canonical request fingerprint, the first status code, and a UTC expiry timestamp.
  • You treat a missing key as 400 with a stable error code, not as a license to create another job.
  • You do not hash only the key; you hash method, path, content type, and a canonical JSON body.

Replay outcomes

Use this table as the source of truth when the agent proposes status codes.

Incoming request Stored fingerprint Key state Status Side effect
First request with valid key none new 202 create one job
Retry, same key and body equal in window 202 no new job
Retry, same key, different body mismatch in window 409 no new job
Same key after expiry ignored expired 202 create one new job
Missing or empty key n/a invalid 400 no job
  • A matching replay must return the original job identifier in the same JSON shape as the first response.
  • A fingerprint mismatch must not overwrite the stored response, the job owner, or the export filter set.
  • Expiry is an absolute UTC timestamp written at insert time, not a relative duration computed during later reads.
  • 202 Accepted is the success code because the export remains asynchronous and the worker is a separate process.

What you deliberately refuse

You refuse implicit “same user means same key” logic, because two tabs can export two different date ranges. You refuse to reuse a key across GET and POST, because safe methods should not share a side-effect record. You refuse 200 with a queued job, because clients will assume the CSV bytes already exist in the response. You refuse to let the agent pick 429 for a key conflict, because that status invites a retry storm.

Implementation: tests first

The following pytest module is a labeled, unexecuted example you can copy into a new repository. It encodes the table above as assertions so a coding agent has a failing contract before it writes routes.

# tests/test_export_job_idempotency.py
# Labeled example: unexecuted contract tests for POST /export-jobs

import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone


def canonical_fingerprint(method: str, path: str, content_type: str, body: dict) -> str:
    payload = {
        "method": method.upper(),
        "path": path,
        "content_type": content_type.split(";")[0].strip().lower(),
        "body": body,
    }
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return hashlib.sha256(blob).hexdigest()


@dataclass
class StoredReplay:
    key: str
    fingerprint: str
    job_id: str
    status: int
    expires_at: datetime


class InMemoryIdempotencyStore:
    def __init__(self):
        self._rows: dict[str, StoredReplay] = {}

    def get(self, key: str, now: datetime) -> StoredReplay | None:
        row = self._rows.get(key)
        if row is None:
            return None
        if row.expires_at <= now:
            del self._rows[key]
            return None
        return row

    def put_if_absent(self, row: StoredReplay) -> StoredReplay:
        existing = self._rows.get(row.key)
        if existing is None:
            self._rows[row.key] = row
            return row
        return existing


def create_export_job(store, key, body, now):
    if not key or len(key) > 128 or not key.isascii() or not key.isprintable():
        return 400, {"error": "invalid_idempotency_key"}

    fingerprint = canonical_fingerprint("POST", "/export-jobs", "application/json", body)
    existing = store.get(key, now)
    if existing is not None:
        if existing.fingerprint != fingerprint:
            return 409, {"error": "idempotency_key_reused", "job_id": existing.job_id}
        return existing.status, {"job_id": existing.job_id, "status": "queued"}

    job_id = f"job_{len(store._rows) + 1}"
    store.put_if_absent(
        StoredReplay(
            key=key,
            fingerprint=fingerprint,
            job_id=job_id,
            status=202,
            expires_at=now + timedelta(hours=24),
        )
    )
    return 202, {"job_id": job_id, "status": "queued"}


def test_missing_key_does_not_create_a_job():
    store = InMemoryIdempotencyStore()
    now = datetime(2026, 9, 23, tzinfo=timezone.utc)
    status, payload = create_export_job(
        store, "", {"from": "2026-09-01", "to": "2026-09-23"}, now
    )
    assert status == 400
    assert payload["error"] == "invalid_idempotency_key"
    assert store._rows == {}


def test_retry_with_same_body_reuses_job():
    store = InMemoryIdempotencyStore()
    now = datetime(2026, 9, 23, tzinfo=timezone.utc)
    body = {"from": "2026-09-01", "to": "2026-09-23"}
    s1, p1 = create_export_job(store, "export-42", body, now)
    s2, p2 = create_export_job(store, "export-42", body, now + timedelta(seconds=30))
    assert s1 == s2 == 202
    assert p1["job_id"] == p2["job_id"]
    assert len(store._rows) == 1


def test_same_key_different_body_conflicts():
    store = InMemoryIdempotencyStore()
    now = datetime(2026, 9, 23, tzinfo=timezone.utc)
    s1, p1 = create_export_job(store, "export-42", {"from": "2026-09-01"}, now)
    s2, p2 = create_export_job(store, "export-42", {"from": "2026-08-01"}, now)
    assert s1 == 202
    assert s2 == 409
    assert p2["job_id"] == p1["job_id"]
    assert len(store._rows) == 1


def test_expired_key_may_create_a_new_job():
    store = InMemoryIdempotencyStore()
    now = datetime(2026, 9, 23, tzinfo=timezone.utc)
    body = {"from": "2026-09-01"}
    s1, p1 = create_export_job(store, "export-42", body, now)
    later = now + timedelta(hours=24, seconds=1)
    s2, p2 = create_export_job(store, "export-42", body, later)
    assert s1 == 202 and s2 == 202
    assert p1["job_id"] != p2["job_id"]
Enter fullscreen mode Exit fullscreen mode

Commands you can run locally

Create a virtualenv, install pytest, and run only the idempotency file before any route generator starts.

python -m venv .venv
source .venv/bin/activate
pip install pytest
pytest tests/test_export_job_idempotency.py -v
Enter fullscreen mode Exit fullscreen mode

You should keep the first run red if you delete create_export_job and leave only the tests and the table. That red run is the contract, and the agent may fill the function but not renegotiate status codes. If a generated handler returns 200 or 201, you fail the suite instead of debating REST taste in review.

Handler sketch after the tests exist

The next block is a labeled sketch, not production code, and it still omits auth, authz, and persistence. It shows how the route should call the same store the tests already exercise, rather than inventing a second path.

# app/export_jobs.py
# Labeled sketch: unexecuted FastAPI-shaped wrapper around the contract above

from datetime import datetime, timezone
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()
STORE = InMemoryIdempotencyStore()


class ExportRequest(BaseModel):
    model_config = {"populate_by_name": True}
    date_from: str = Field(alias="from")
    date_to: str | None = Field(default=None, alias="to")


@app.post("/export-jobs")
def post_export_jobs(
    body: ExportRequest,
    idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
    now = datetime.now(timezone.utc)
    status, payload = create_export_job(
        STORE,
        idempotency_key or "",
        body.model_dump(by_alias=True, exclude_none=True),
        now,
    )
    if status >= 400:
        raise HTTPException(status_code=status, detail=payload)
    return payload
Enter fullscreen mode Exit fullscreen mode

You still need a unique constraint on the key column when you replace the dictionary with a real database. You still need a transaction that inserts the job row and the replay row together, or neither row. You should add a worker later; this case study stops at the admission control that prevents duplicate jobs.

Results you should expect from the suite

These results are expected assertions for the labeled example, not measured production metrics from a live system. The missing-key case returns 400 and leaves the in-memory store empty, so retries without a key still fail closed. The matching replay returns the same job_id and leaves a single stored row, which is the whole point. The mismatched body returns 409 while preserving the original job, so a confused client cannot widen the export. The expired key creates a new job, which is a product choice you must document for operators and for support.

If an agent “simplifies” fingerprinting to the key alone, test_same_key_different_body_conflicts should stay red. If an agent maps every conflict to 429, you add an assertion on the status code and reject the patch. If an agent uses local time for expiry, you keep the fixture pinned to timezone.utc so the test fails loudly.

Where a coding assistant fits

A coding assistant can still draft the handler once the pytest file is red and the table is in the repo.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open-source project with free model access and a free server option you can use for this loop. You point the assistant at tests/test_export_job_idempotency.py and refuse to merge until every replay case is green.

Keep the product mention contextual: the value is the contract, and the assistant is only the typist behind it. You should paste the decision table into the prompt as immovable constraints, not as optional style suggestions. You should also forbid new status codes unless the assistant updates the table and the tests in the same change.

Limitations

This store is process-local, so two app replicas will not share replay state and can still duplicate jobs. The fingerprint uses canonical JSON, which will disagree with clients that send equivalent bodies with different key order. That is why the server must parse, then re-serialize with sorted keys, instead of hashing the raw request bytes. The example does not persist CSV files, send email, or prove that the worker itself is idempotent on job_id.

Twenty-four hours is a product choice, not a universal constant, and this article does not benchmark expiry workloads. Unicode keys are rejected by the ASCII rule, which may be too strict for some mobile clients you do not control. The sketch does not handle request bodies larger than a small filter document, and it does not stream uploads. Clock rollback on the host can resurrect expired keys; a monotonic expiry source belongs in a later hardening pass.

Who should not use this approach

Do not use this pattern for pure GET handlers, because caching and conditional requests already cover safe replay. Do not use a single in-memory dictionary if you already run multiple replicas, workers, or independently scaled API nodes. Do not copy the 409 rule if your public API already promised that reused keys always succeed and return the first body. Do not ask an agent to invent payment capture semantics from this export-job example; money movement needs a stricter ledger.

If your clients cannot send an Idempotency-Key header, you need a different dedupe strategy based on a business natural key. That alternative is a different contract, and you should write a different table instead of stretching this one.

Lessons learned

The expensive mistake is letting generated code choose conflict behavior while you only review the happy-path handler. A small export endpoint is enough to show the failure, because retries are normal and duplicate CSVs are user-visible. Write the table, write the tests, then let an assistant fill the function that the tests already constrain. If a later change needs a new status code, you update the table first so the agent cannot “simplify” the API.

Keep prompts short: attach the test file, attach the table, and name the status codes that are forbidden. Reviewers should reject fingerprints that hash raw bytes, because whitespace and key order will create false conflicts. Reviewers should also reject 429 on key reuse, because well-behaved clients will retry and amplify the original fault.

You leave the case study when the suite is green and the table is the only place status codes can change. If you want a coding agent on a free server to fill the handler, give it the failing tests first.

Top comments (0)