Summary
The HTTP client retries every request on transient failures, regardless of HTTP method. _is_retryable_error treats all impit.HTTPError subclasses as retryable, and _make_request retries any 429 or 5xx response (src/apify_client/http_clients/_impit.py, sync at ~L235-247, async at ~L496-508).
A POST can commit server-side and still be retried when the response never reaches the client — a dropped connection, or a gateway 5xx emitted after the write. The retry then repeats an operation that already happened.
Observed in this CI run: attempt = 2, method = 'POST', url = 'https://api.apify.com/v2/actors', followed by ConflictError: Some other Actor already has this name ("python-client-test-actor-0xjp5vwi") — on a name generated randomly moments earlier, so the only thing that could have taken it was the client's own first attempt.
Impact
Two flavours, depending on whether the endpoint has a uniqueness constraint.
1. Fails loudly, with a misleading error. Creates of named resources reject the retry:
| Call |
Error on retry |
actors().create() |
409 actor-name-not-unique |
tasks().create() |
409 actor-task-name-not-unique |
schedules().create() |
409 schedule-name-not-unique |
versions().create() |
403 version-already-exists |
env_vars().create() |
403 env-var-already-exists |
The resource does exist, but the caller gets a conflict error and no way to distinguish it from a genuine name collision. This is the flake worked around test-side in #971 — CI is green, callers still hit it.
2. Silently duplicates the side effect. Endpoints without a uniqueness constraint accept the retry and no error surfaces:
actor.start() / actor.call() / task.start() / task.call() — POST /v2/acts/{id}/runs has no idempotency mechanism, so the Actor starts twice. The extra run consumes compute and is billed.
webhooks().create() — the API accepts an idempotencyKey, and create() exposes it, but the client never populates one. Callers who leave it unset get two webhooks for the same event, and every trigger fires twice.
dataset.push_items() — items pushed twice.
Notes towards a fix
- Idempotency keys are the proper fix. There is already a precedent in this client:
run.charge() generates an idempotency-key header when the caller doesn't supply one (_resource_clients/run.py). Extending the same pattern is cheap where the API supports it (webhooks), but it needs platform support for the endpoints that matter most (run start, resource creates).
- Retrying
429 is always safe — the request was rejected, not processed. Only 5xx and transport errors are ambiguous.
- Simply not retrying non-idempotent methods is a behaviour change, not a pure fix: transient failures that happen before the commit are currently rescued by the retry, and would start surfacing as errors.
apify-client-js retries POST the same way (src/http_client.ts), so whatever we settle on should be aligned across clients.
✍️ Drafted by Claude Code
Summary
The HTTP client retries every request on transient failures, regardless of HTTP method.
_is_retryable_errortreats allimpit.HTTPErrorsubclasses as retryable, and_make_requestretries any429or5xxresponse (src/apify_client/http_clients/_impit.py, sync at ~L235-247, async at ~L496-508).A
POSTcan commit server-side and still be retried when the response never reaches the client — a dropped connection, or a gateway5xxemitted after the write. The retry then repeats an operation that already happened.Observed in this CI run:
attempt = 2, method = 'POST', url = 'https://api.apify.com/v2/actors', followed byConflictError: Some other Actor already has this name ("python-client-test-actor-0xjp5vwi")— on a name generated randomly moments earlier, so the only thing that could have taken it was the client's own first attempt.Impact
Two flavours, depending on whether the endpoint has a uniqueness constraint.
1. Fails loudly, with a misleading error. Creates of named resources reject the retry:
actors().create()409 actor-name-not-uniquetasks().create()409 actor-task-name-not-uniqueschedules().create()409 schedule-name-not-uniqueversions().create()403 version-already-existsenv_vars().create()403 env-var-already-existsThe resource does exist, but the caller gets a conflict error and no way to distinguish it from a genuine name collision. This is the flake worked around test-side in #971 — CI is green, callers still hit it.
2. Silently duplicates the side effect. Endpoints without a uniqueness constraint accept the retry and no error surfaces:
actor.start()/actor.call()/task.start()/task.call()—POST /v2/acts/{id}/runshas no idempotency mechanism, so the Actor starts twice. The extra run consumes compute and is billed.webhooks().create()— the API accepts anidempotencyKey, andcreate()exposes it, but the client never populates one. Callers who leave it unset get two webhooks for the same event, and every trigger fires twice.dataset.push_items()— items pushed twice.Notes towards a fix
run.charge()generates anidempotency-keyheader when the caller doesn't supply one (_resource_clients/run.py). Extending the same pattern is cheap where the API supports it (webhooks), but it needs platform support for the endpoints that matter most (run start, resource creates).429is always safe — the request was rejected, not processed. Only5xxand transport errors are ambiguous.apify-client-jsretriesPOSTthe same way (src/http_client.ts), so whatever we settle on should be aligned across clients.✍️ Drafted by Claude Code