Skip to content

fix(api/submissions): make POST idempotent via Idempotency-Key header#2426

Open
AybH26 wants to merge 1 commit into
codalab:developfrom
AybH26:fix/lost-submissions-client-side
Open

fix(api/submissions): make POST idempotent via Idempotency-Key header#2426
AybH26 wants to merge 1 commit into
codalab:developfrom
AybH26:fix/lost-submissions-client-side

Conversation

@AybH26

@AybH26 AybH26 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

@ mention of reviewers

@ @

A brief description of the purpose of the changes contained in this PR

POST /api/submissions/ was not idempotent. Any client retry — network timeout, double-click on the Submit button, proxy / load-balancer retransmission after a 504 — created a brand-new Submission row, enqueued a brand-new compute_worker_run task, and produced a duplicate leaderboard entry. There was no server-side dedup and no unicity constraint protecting against retries.

This PR introduces Stripe-style idempotency on submission creation:

  1. A new IdempotencyRecord model keyed by (owner, endpoint, key) (unique_together).
  2. An IdempotentCreateMixin that wraps the SubmissionViewSet.create() flow: it reads the Idempotency-Key request header, atomically reserves the key, replays the original response on retry, and rejects mismatched payloads with 409 Conflict (Stripe semantics).
  3. A GET /api/submissions/receipt/<key>/ endpoint so a client that lost its connection mid-POST can pull the original response back without replaying.

Issues this PR resolves

Closes #2425

Symptoms reported / reproduced:

  • Scenario A — client times out, retries the same POST /api/submissions/ → 2 Submission rows, 2 worker runs, 2 leaderboard entries.
  • Scenario B — user double-clicks "Submit" → same duplication.
  • Scenario C — proxy retransmits the request (504 then retry) → same duplication.

Root cause: no idempotency layer on submission creation. Each retry was treated as a brand-new request.

Fix: header-driven Stripe-style idempotency.

  • New model IdempotencyRecord(owner, endpoint, key) with unique_together and a request_fingerprint so the same key with a different payload is rejected (409 Conflict) instead of silently aliased.
  • IdempotentCreateMixin plugged into SubmissionViewSet:
    • missing header on a POST that requires it → 400 Bad Request,
    • first call → get_or_create reserves a PENDING record, executes the real create, stores response_status + response_body + FK to the Submission,
    • retry with same key + same fingerprint → returns the stored response untouched (no second row, no second worker job),
    • retry with same key + different fingerprint → 409 Conflict.
  • New action GET /api/submissions/receipt/<key>/:
    • 404 if no record,
    • 202 if still PENDING (creation in flight),
    • 200 with the original response body once finalised.

A checklist for hand testing

  • A — retry after timeout: POST a submission with Idempotency-Key: <uuid> → 201. Replay the exact same POST → 200/201 with the same body, only one row in Submission, only one worker run.
  • B — double-click: send two simultaneous POSTs with the same key from two terminals → one wins, the other replays the original response. Single submission in DB.
  • C — proxy retransmit: same POST from curl --retry 3 simulating a 504 between gateway and Django → single submission in DB.
  • Mismatched fingerprint: reuse the same key with a different phase / data payload → 409 Conflict.
  • Missing header: POST without Idempotency-Key400 Bad Request (or current behaviour if the header is opt-in — verify with reviewer).
  • Receipt 404: GET /api/submissions/receipt/never-seen-key/404.
  • Receipt 202: simulate a slow create (block in serializer) and GET /receipt/<key>/ while still pending → 202.
  • Receipt 200: after completion, GET /receipt/<key>/200 with the same body the first POST returned.
  • Quota check: confirm the participant's daily submission quota is incremented once, not per retry.
  • Leaderboard: confirm only one row appears on the leaderboard after retries.
  • DB sanity: one new IdempotencyRecord row per logical submission attempt; FK submission_id set after the create.

Any relevant files for testing

  • New: src/apps/api/idempotency.pyIdempotentCreateMixin.

  • Modified: src/apps/api/views/submissions.py — mixin plugged into SubmissionViewSet, new receipt action.

  • New model: src/apps/competitions/models.py (IdempotencyRecord).

  • Migration: src/apps/competitions/migrations/0062_idempotencyrecord.py — additive, no backfill.

  • Sample requests for hand-testing (drag-and-drop or copy from this PR description):

    KEY=$(uuidgen)
    curl -X POST http://localhost/api/submissions/ \
         -H "Idempotency-Key: $KEY" \
         -H "Authorization: Token <admin-token>" \
         -F "phase=1" -F "data=<dataset-uuid>"
    
    # replay -> same body, single DB row
    curl -X POST http://localhost/api/submissions/ \
         -H "Idempotency-Key: $KEY" \
         -H "Authorization: Token <admin-token>" \
         -F "phase=1" -F "data=<dataset-uuid>"
    
    # receipt
    curl http://localhost/api/submissions/receipt/$KEY/ \
         -H "Authorization: Token <admin-token>"
    

Checklist

  • Code review by me
  • Hand tested by me
  • I'm proud of my work
  • Code review by reviewer
  • Hand tested by reviewer
  • CircleCi tests are passing
  • Ready to merge

@AybH26 AybH26 force-pushed the fix/lost-submissions-client-side branch from fa09f4e to 5a48517 Compare June 19, 2026 13:45
@IdirLISN

Copy link
Copy Markdown
Collaborator

Seems good to me, but a little detail about the model file :
"unique_together" is a method from an old version of django, see:

"unique_together": {("owner", "endpoint", "key")},

better use:

constraints = [
    models.ExempleConstraint(...)
]

see: https://docs.djangoproject.com/en/5.2/ref/models/options/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lost submissions on client side: POST /api/submissions/ is not idempotent

2 participants