Skip to main content

Allow cookies so we can count page views, see which pages and campaigns bring people here, and measure our ads on Meta, Google and LinkedIn. Cookie policy

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:

  1. No Idempotency-Key header. Nothing changes — the handler simply runs (idempotency.py:144-147).
  2. Same key, same body. The stored response is replayed, the handler is not run again, and the response carries an Idempotency-Replayed: true header (idempotency.py:153-158, :196-201).
  3. Same key, a different body. Rejected with 409 idempotency_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.
  4. The key is still in flight. A second request carrying the same key while the first is still being processed is rejected with 409 idempotency_key_in_progress (idempotency.py:154-155). The middleware claims the key with an in_progress marker 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).