How do I make a POST /v1/calls retry safe?
IdempotencyMiddleware sits in front of every state-changing /v1 request
and gives a retried request a way to say "this is the same attempt, not a new
one" (backend/app/api/v1/idempotency.py). It applies to any POST, PUT
or PATCH under /v1 — IDEMPOTENT_METHODS (idempotency.py:45), and the
middleware is mounted with prefix="/v1" — not only to
POST /v1/calls. Register a trunk, bind a
number, submit a turn: all of it is covered.
Why it matters most for placing a call
A network timeout doesn't tell you whether the request it interrupted
actually ran. For most /v1 endpoints, retrying blind is at worst wasteful.
For POST /v1/calls, retrying blind means the second attempt can place a
second real telephone call to the same person — duplicate spend at best, and
on a regulated outbound campaign, a repeat-contact compliance incident. An
Idempotency-Key turns "did that actually go through?" from a guess into a
provable replay.
The four behaviours
Send the key as an Idempotency-Key header, any string unique to one
logical attempt (a UUID is the usual choice). The middleware fingerprints
the request by method, path and body (_fingerprint,
idempotency.py:52-59) and behaves as follows:
- No
Idempotency-Keyheader. Nothing changes — the handler simply runs (idempotency.py:144-147). - Same key, same body. The stored response is replayed, the handler is
not run again, and the response carries an
Idempotency-Replayed: trueheader (idempotency.py:153-158,:196-201). - Same key, a different body. Rejected with
409idempotency_key_reuse(idempotency.py:156-157). One key is scoped to one exact request; reuse it for a logically different request and it's treated as a mistake, not a new attempt. - The key is still in flight. A second request carrying the same key
while the first is still being processed is rejected with
409idempotency_key_in_progress(idempotency.py:154-155). The middleware claims the key with anin_progressmarker before it calls the handler, specifically so two concurrent retries can't both run — storing only on completion would let a second, simultaneous copy of the request through before the first one finished (idempotency.py:160-164).
curl -X POST "$VARTA_BASE_URL/calls" \
-H "Authorization: Bearer $VARTA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-ceea-467e-bd36-6df963f3f223" \
-d '{"agent_id": "ag_412", "to": "+9198xxxxxxx", "dial": true}'A retry of exactly this request, same key and same body, gets back the
original response with Idempotency-Replayed: true — no second call is
placed.
A failed attempt is not stored
If the handler's response is a 5xx, the middleware deletes the in-progress
marker instead of storing the outcome (idempotency.py:172-175 — "5xx is an
unsettled outcome — release the key so a retry can retry"). A 2xx or a
4xx is stored: both are settled outcomes — the request either
succeeded or was rejected for a reason that won't change on retry — while a
5xx means the platform doesn't actually know what happened, so a retry
with the same key must be free to actually retry rather than replaying an
unreliable result. The same release happens if the handler raises an
exception outright (idempotency.py:166-170).
Storage
Stored records live for 24 hours (TTL_SECONDS, idempotency.py:43); an
in-progress claim lives for 15 minutes so a process that dies mid-request
doesn't wedge the key for a day (IN_PROGRESS_TTL_SECONDS,
idempotency.py:47-49). Records are kept in the shared, file-backed store
(app.core.store), not an in-process dict — a production deployment runs
more than one API process, and an in-memory dict would only dedupe requests
that happened to land on the same worker, which is no protection at all
against a load-balanced retry (idempotency.py:21-26).