Skip to content

fix(sdk): stop replaying timed-out POSTs and send an x-request-id on every request - #42

Merged
Zernio-Elean merged 2 commits into
developfrom
fix/sdk-no-post-replay-on-timeout
Sep 4, 2026
Merged

fix(sdk): stop replaying timed-out POSTs and send an x-request-id on every request#42
Zernio-Elean merged 2 commits into
developfrom
fix/sdk-no-post-replay-on-timeout

Conversation

@Zernio-Elean

@Zernio-Elean Zernio-Elean commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

A customer building publishing on this SDK got an HTTP 409 duplicate-content back from POST /v1/posts for a post that had already published live on Threads. Reading the 409 as "nothing went out", they retried by hand with a one-character caption change, which dodged the server's content-hash dedup and produced a second live post.

The 409 was never the bug. The SDK was replaying a POST it had no business replaying.

  • A publishNow create runs the whole cross-platform publish synchronously inside the request. One measured Threads publish took 222s against BaseClient.DEFAULT_TIMEOUT's 30s, so httpx aborted while the server was still working.
  • _request_with_retry caught the httpx.TimeoutException and fired the same POST again, up to 3 times.
  • The SDK sent no x-request-id, so the server could not match the replay to the original and its idempotency window was unreachable from Python. The replay hit the content-hash dedup and answered 409 while the original request was still publishing.

Changes

fix(client) commit 1 - _with_request_id mints a UUIDv4 x-request-id and is called once before the retry loop in both _request_with_retry and _arequest_with_retry, so every attempt of the same logical call carries the same id. setdefault means a caller-supplied id always wins, so the x_request_id kwarg PR #41 wired on create_post keeps working. This closes the first entry in PR #41's own "Known gaps" list.

It reads kwargs.get("headers"), not kwargs["headers"]: _put, _patch and _delete pass no headers kwarg at all, so the subscript form would be a KeyError on every PUT the SDK makes.

fix(client) commit 2 - a POST that times out client-side is no longer retried. It raises immediately with a message that names the risk instead of the old generic Request timed out:

POST /v1/posts timed out and was NOT retried: the request may have completed server-side. Check before retrying; retrying may create a duplicate.

That message matters as much as the behaviour change. The old one is what sent this customer off to retry by hand.

httpx.ConnectError still retries on POST, since the connection was never established and nothing ran server-side. PUT, PATCH and DELETE stay retryable on timeout, they are idempotent by contract.

publishNow creates also get a publish_timeout of 300s, configurable via Zernio(publish_timeout=...), resolved in _resolve_timeout.

On the body sniff

_resolve_timeout reads publishNow out of the JSON body from inside the transport layer. That is a layering leak and the docstring says so in those words.

It was taken deliberately: it is the only place that covers all three publish callers at once, the hand-written posts.create, the generated create_post and the MCP server, and it survives regeneration, which base.py does and _generated/ does not. The alternative is a scripts/generate_resources.py change plus a 58-file regen, and it would still miss the hand-written path. The proper fix is named in the docstring so the exit is on record rather than buried.

Testing

The regression tests are deliberately not part of this diff. They were written and run against this branch in the fix worktree; the tables below are that run. Treat this section as the evidence, since the diff itself carries none.

tests/test_post_replay_regression.py, 12 tests, transport-level via respx with no internal mocking. Failures are injected as real httpx.ReadTimeout / httpx.ConnectError at the transport boundary.

On this branch:

Test Result
test_create_post_sends_generated_request_id_and_keeps_authorization PASSED
test_caller_supplied_request_id_is_not_overwritten PASSED
test_put_reaches_the_wire_with_a_request_id PASSED
test_retried_get_reuses_one_request_id_across_attempts PASSED
test_timed_out_post_raises_after_exactly_one_attempt PASSED
test_timed_out_post_error_says_it_was_not_retried PASSED
test_connect_error_on_post_still_retries PASSED
test_publish_now_create_uses_the_publish_timeout PASSED
test_plain_create_uses_the_default_timeout PASSED
test_publish_timeout_constructor_override_reaches_the_wire PASSED
test_async_timed_out_post_raises_after_exactly_one_attempt PASSED
test_async_create_sends_generated_request_id PASSED
12 passed in 0.25s

The same 12 with base.py and late_client.py reverted to origin/develop, to show they fail for the real defect and not by construction:

Test Against origin/develop What it pins
test_create_post_sends_generated_request_id_and_keeps_authorization FAILED no x-request-id was sent at all
test_put_reaches_the_wire_with_a_request_id FAILED same, on the verb that passes no headers kwarg
test_retried_get_reuses_one_request_id_across_attempts FAILED id must be minted outside the retry loop
test_timed_out_post_raises_after_exactly_one_attempt FAILED the ticket's regression. Asserts route.call_count == 1 on the wire
test_timed_out_post_error_says_it_was_not_retried FAILED the old message said nothing about the duplicate risk
test_publish_now_create_uses_the_publish_timeout FAILED publishNow ran at 30s
test_publish_timeout_constructor_override_reaches_the_wire FAILED publish_timeout did not exist
test_async_timed_out_post_raises_after_exactly_one_attempt FAILED the async loop is a separate code path
test_async_create_sends_generated_request_id FAILED same, async
test_caller_supplied_request_id_is_not_overwritten passed guard: the generated resource already forwarded an explicit id. Post-fix it is the only thing pinning setdefault over [...] =
test_connect_error_on_post_still_retries passed guard: pins the exemption, must hold both ways
test_plain_create_uses_the_default_timeout passed guard: the 30s half of the timeout pair
9 failed, 3 passed in 0.31s

The three that pass both ways are deliberate guards, not tests that pass for the wrong reason.

test_put_reaches_the_wire_with_a_request_id was additionally verified by patching kwargs.get("headers") to kwargs["headers"] and re-running: it fails with a KeyError and nothing else in the file does. It is the only pin on that distinction.

Other checks on this branch:

uv run pytest --no-cov -q     244 passed, 14 skipped
uv run mypy src               Success: no issues found in 117 source files
uv run ruff check             clean on all touched files

Commit 1 passes standalone (232 passed / 14 skipped), so the split stays bisectable.

Pre-fix baseline is 232 passed / 14 skipped. Run uv sync --extra dev --extra mcp first or pytest collects a smaller set and the numbers will not line up.

Targeting develop

Deliberately, not main. The auto-regen workflow publishes the wheel from develop, and CHANGELOG.md [1.4.49] documents what happens otherwise: the _patch fix landed on main only and develop's next regen republished the wheel without it.

Known gaps, deliberately out of scope

  • No regression test ships with this PR, so nothing prevents a future change from reintroducing either defect. Same gap PR fix(sdk): surface API error bodies on 4xx/5xx and stop dropping header params #41 declared.
  • A bare timeout=<float> in httpx overrides all four components, not just read. So a publishNow create now also carries a 300s connect timeout, meaning an unreachable host blocks for five minutes instead of thirty seconds. httpx.Timeout(publish_timeout, connect=timeout) is the tighter form. Left out because it deviates from the reviewed plan and the failure mode is rare, but it is a real regression in that one case.
  • PUT /v1/posts/{id} with publishNow has the same exposure: still a 30s timeout on a synchronous publish, and still replayed 3x. Narrower than create, since a replay hits the same document rather than creating a new one.
  • Multipart uploads lose their timeout retry. media.*, upload/direct and posts.bulk_upload are POSTs; they previously retried 3x on timeout and now hard-fail on the first. Defensible, since a replayed partial upload duplicates a media asset, but it is a reliability trade on a path with no duplicate-post risk.
  • An MCP posts_publish_now call can now block up to 300s, which may exceed the client's own tool timeout. If the client gives up and the LLM re-invokes, that mints a fresh id and misses the idempotency window. Still strictly better than today, where the SDK's own replay could produce up to 3 duplicates on top of that, but not eliminated.
  • 5xx responses are never retried at all. LateAPIError is not caught by either retry loop, so the timeout branch was effectively the entire retry surface. Pre-existing, untouched here.
  • The $ref header-params gap from PR fix(sdk): surface API error bodies on 4xx/5xx and stop dropping header params #41 is still open.

Server side, not fixable here

Even with a matching x-request-id, the API's content-hash dedup runs before the idempotent claim (create.ts:1001 vs :485), so a retry during publishing still answers 409 while the original goes live. That ordering belongs to the API repo. Not replaying is what actually closes the customer-facing hole from this side.

Crisp

https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_8e5d3e6e-1e10-4a33-95f1-0b1e33d119da/

Customer partnerships@artdailydose.com, userId 6a79041e4812f4a30ffbbe69.
Post A 6a9894c6799b0c3b2e53edf4 -> Threads 17972389461067916.
Duplicate B 6a989578eb3b03d24b29bb36 -> 18113507188969526, deleted by the customer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QyoePpXUo8PHpGUr9JTu23

…ries

The SDK never emitted x-request-id, so the server could not match a
replayed request to the original and its idempotency window was
unreachable from Python. openapi.yaml already claims the official SDKs
send one. Minted once before the retry loop so every attempt of the same
logical call carries the same id; a caller-supplied id always wins.

Closes the first item in PR #41's "Known gaps".
A publishNow create publishes synchronously and can run for minutes; one
measured Threads publish took 222s against a 30s DEFAULT_TIMEOUT. httpx
aborted mid-publish and _request_with_retry replayed the POST, so the
customer got a 409 for a post that had gone live plus a duplicate live
post. POST is non-idempotent by contract, so a client-side timeout now
raises instead of replaying; ConnectError still retries, since nothing
reached the server. publishNow creates get a 300s timeout, overridable
via Zernio(publish_timeout=...).

Crisp: https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_8e5d3e6e-1e10-4a33-95f1-0b1e33d119da/
@Zernio-Elean
Zernio-Elean force-pushed the fix/sdk-no-post-replay-on-timeout branch from 4265d04 to 0ed9bce Compare September 4, 2026 14:38
@Zernio-Elean
Zernio-Elean merged commit 10ad61d into develop Sep 4, 2026
4 checks passed
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.

1 participant