Idempotency & retries
Teler is a system of record for real-world side effects: phone calls cost money, ring real phones, and cannot be undone. Retries must be deliberate.
This page covers two retry surfaces:
- Client to Teler: when you call the API and the response is in doubt.
- Teler to your webhook endpoint: when Teler delivers an event and isn’t sure you received it.
Client → Teler
Section titled “Client → Teler”What is idempotent
Section titled “What is idempotent”| Operation | Idempotent? | Why |
|---|---|---|
GET reads | Yes | No state mutation. |
POST /api/v1/voice/calls/initiate | No | Every call creates a new call record and dials a real phone. |
Recommended retry strategy
Section titled “Recommended retry strategy”For non-idempotent operations like calls.create():
- Set a tight timeout on the request: 5 to 10 seconds is plenty.
- On success, you’re done. Persist the returned
call_id. - On a non-2xx that’s clearly a client error (
400,403,422), don’t retry. Fix the input. - On a 5xx or transport failure, retry at most once, with backoff and jitter, and only if you’re confident the original request did not reach the server.
- On uncertainty, wait for the
call.initiatedwebhook before deciding. If it arrives within a few seconds, the call exists and you don’t need to retry.
Backoff for read operations
Section titled “Backoff for read operations”For idempotent GET-style operations, exponential backoff with jitter:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 1s + jitter |
| 3 | 2s + jitter |
| 4 | 4s + jitter |
| 5 | 8s + jitter |
Stop after 3 to 5 attempts. If the platform is degraded for longer, back off to a circuit-breaker rather than continuing to load it.
import asyncioimport randomfrom teler import AsyncClientfrom teler.exceptions import TelerException
async def fetch_with_retry(client, op, max_attempts=4): for attempt in range(1, max_attempts + 1): try: return await op() except TelerException as e: if e.code < 500 or attempt == max_attempts: raise delay = (2 ** (attempt - 1)) + random.random() await asyncio.sleep(delay)import { TelerException } from "@frejun/teler";
async function fetchWithRetry<T>(op: () => Promise<T>, maxAttempts = 4): Promise<T> { let lastError: unknown; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await op(); } catch (err) { lastError = err; if (err instanceof TelerException && err.code < 500) throw err; if (attempt === maxAttempts) break; const delay = (2 ** (attempt - 1)) * 1000 + Math.random() * 1000; await new Promise((r) => setTimeout(r, delay)); } } throw lastError;}Idempotent call-control mutations
Section titled “Idempotent call-control mutations”While calls.create() is not idempotent, the call-control mutation
endpoints are. POST /api/v1/voice/calls/{call_id}/hangup, /mute, /dtmf,
and /play all require an Idempotency-Key header (up to 255 characters).
Teler fingerprints the request against that key, locks in-progress duplicates,
and replays the stored response for retries (TTL ~24h). This means you can
safely retry these mutations on a network timeout: reusing the same
Idempotency-Key returns the original result instead of applying the action
twice. See Call control for the full contract.
Teler → your webhook endpoint
Section titled “Teler → your webhook endpoint”Teler retries webhook delivery up to 5 times with backoff (immediate to ~30 min). See Webhooks retries for the full schedule.
This means your endpoint will receive the same event more than once under unusual conditions. Build for it.
Dedupe on receive
Section titled “Dedupe on receive”Every event envelope carries a unique id (an evt_<ULID>, also echoed in the
X-Teler-Event-Id header). Persist an (id, processed_at) row on first
receipt; reject duplicates idempotently:
async def handle_webhook(event): if await db.events.exists(event["id"]): return # already processed; idempotent ack
await db.events.insert(event["id"]) await process_async(event)async function handleWebhook(event: TelerEvent) { if (await db.events.exists(event.id)) return; await db.events.insert(event.id); await processAsync(event);}Stay under the 5-second timeout
Section titled “Stay under the 5-second timeout”Webhook handlers must return 2xx within 5 seconds. The pattern is always:
1. Verify signature2. Check dedup table3. Enqueue for async work4. Return 200 OKDoing real work inline, such as DB writes, downstream API calls, and slow integrations, pushes you past the timeout, triggers retries, and creates more duplicates.
Don’t double-record
Section titled “Don’t double-record”A real-world failure mode: your handler does work + acks, but the network drops the ack. Teler retries, your dedup table catches it, but only if you inserted into the dedup table before doing the work. Otherwise the work runs twice.