Skip to content

Don't retry create_phone_call without an idempotency_key - #552

Closed
zorionarrillaga wants to merge 1 commit into
RetellAI:mainfrom
zorionarrillaga:fix/no-keyless-phone-call-retry
Closed

zorionarrillaga wants to merge 1 commit into
RetellAI:mainfrom
zorionarrillaga:fix/no-keyless-phone-call-retry

Conversation

@zorionarrillaga

Copy link
Copy Markdown

What broke

create_phone_call is POST /v2/create-phone-call, and it places an outbound phone call. The Stainless base client retries it up to 2 times (DEFAULT_MAX_RETRIES):

  • on a read timeout;
  • on any connection error, including a connection dropped mid-reply;
  • on 408, 409, 429 and 5xx.

A timeout or a 5xx can come after Retell has already dialled, such as when carrier or LLM setup is slow. If the caller passed no idempotency_key, the API can't tell the retry from a new call. The callee's phone rings twice, both calls are billed, and the caller only gets back the second call_id.

How it was reproduced

I used a local mock of POST /v2/create-phone-call on 127.0.0.1 only; no Retell account or real API was involved. The mock records each POST as a dialled call and holds the first reply for 2.5 s. The client runs with timeout=1.0. One client.call.create_phone_call(from_number=..., to_number="+14155550199"):

PyPI 6.2.10 current main (8fd9533) this branch
calls dialled by the mock 2 (call_1, call_2) 2 (call_1, call_2) 1 (call_1)
caller sees success, call_2 success, call_2 APITimeoutError
  • With idempotency_key="order-42-call", the branch still retries, and both attempts carry the same key. The mock does not dedupe, so it dials twice there. The real API dedupes on the key according to its docs (read, not tested).
  • main differs from the 6.2.10 wheel only in two types/*list_response.py files.

What changed

  • resources/call.py, create_phone_call, in both sync and async: when idempotency_key is not given, the request options set max_retries=0. A comment explains why. Nothing else in the request changes.
  • Unchanged:
    • a call with idempotency_key keeps every retry, with the same key in the body;
    • every other method, including create_web_call, retries as before.
  • tests/test_phone_call_retries.py (new, 22 cases). It uses a scripted httpx transport through the real Retell/AsyncRetell clients:
    • a keyless call is sent exactly once after a read timeout, a dropped connection, 500/502/503/504/408/409 or 429 (9 cases);
    • a keyless call stays at one attempt even with client.with_options(max_retries=5);
    • a keyed call is retried after each of those 9 failures, with the same idempotency_key in both bodies (9 cases);
    • create_web_call is still retried on 503;
    • the async keyless call is sent once, and the async keyed call is retried with the same key.

How it was tested

  • New tests: 11 of 22 fail on current main (every "sent once" case). The 11 "still retried" cases pass on both. All 22 pass on the branch.
  • Full suite (pytest, their config with -n auto), Python 3.9.12, dev deps from their requirements-dev.lock: 518 passed, 2345 skipped on the branch. HEAD gives 496 passed, 2345 skipped. The skips are their disabled mock-server tests.
  • ruff check . (0.14.13) and ruff format --check on both changed files: clean.
  • pyright (1.1.399, their pin): 0 errors, 0 warnings, 0 informations.
  • mypy . (1.17.0): Success: no issues found in 193 source files. python -c 'import retell' works.
  • Real-socket mock repro: 2 dials before the change, 1 after (table above).

Without a key the API can't tell a retry from a new call, so the default
retry after a timeout, a dropped connection or a 5xx could dial to_number
twice. Calls made without idempotency_key are now sent once; calls with a
key keep their retries and resend the same key.

Copy link
Copy Markdown
Contributor

Thanks for the repro and tests. This is addressed in retell-sdk 6.2.11 for Python and TypeScript, through shared request defaults and Python generator support.

Phone-call, SMS-chat, and batch-call creation now default to zero retries, including sync/async Python and phone calls with an idempotency_key. Unrelated methods retain their retry behavior. Python callers can explicitly set per-request max_retries, or retry phone calls themselves with the same idempotency_key.

Verified against both published packages with mocked transports. Closing this as addressed by the release.

@fedorkar fedorkar closed this Oct 23, 2026
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.

2 participants