fix(sdk): stop replaying timed-out POSTs and send an x-request-id on every request - #42
Merged
Merged
Conversation
…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
force-pushed
the
fix/sdk-no-post-replay-on-timeout
branch
from
September 4, 2026 14:38
4265d04 to
0ed9bce
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A customer building publishing on this SDK got an HTTP 409 duplicate-content back from
POST /v1/postsfor 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.
publishNowcreate runs the whole cross-platform publish synchronously inside the request. One measured Threads publish took 222s againstBaseClient.DEFAULT_TIMEOUT's 30s, so httpx aborted while the server was still working._request_with_retrycaught thehttpx.TimeoutExceptionand fired the same POST again, up to 3 times.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_idmints a UUIDv4x-request-idand is called once before the retry loop in both_request_with_retryand_arequest_with_retry, so every attempt of the same logical call carries the same id.setdefaultmeans a caller-supplied id always wins, so thex_request_idkwarg PR #41 wired oncreate_postkeeps working. This closes the first entry in PR #41's own "Known gaps" list.It reads
kwargs.get("headers"), notkwargs["headers"]:_put,_patchand_deletepass noheaderskwarg at all, so the subscript form would be aKeyErroron 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 genericRequest timed out:That message matters as much as the behaviour change. The old one is what sent this customer off to retry by hand.
httpx.ConnectErrorstill 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.publishNowcreates also get apublish_timeoutof 300s, configurable viaZernio(publish_timeout=...), resolved in_resolve_timeout.On the body sniff
_resolve_timeoutreadspublishNowout 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 generatedcreate_postand the MCP server, and it survives regeneration, whichbase.pydoes and_generated/does not. The alternative is ascripts/generate_resources.pychange 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 viarespxwith no internal mocking. Failures are injected as realhttpx.ReadTimeout/httpx.ConnectErrorat the transport boundary.On this branch:
test_create_post_sends_generated_request_id_and_keeps_authorizationtest_caller_supplied_request_id_is_not_overwrittentest_put_reaches_the_wire_with_a_request_idtest_retried_get_reuses_one_request_id_across_attemptstest_timed_out_post_raises_after_exactly_one_attempttest_timed_out_post_error_says_it_was_not_retriedtest_connect_error_on_post_still_retriestest_publish_now_create_uses_the_publish_timeouttest_plain_create_uses_the_default_timeouttest_publish_timeout_constructor_override_reaches_the_wiretest_async_timed_out_post_raises_after_exactly_one_attempttest_async_create_sends_generated_request_idThe same 12 with
base.pyandlate_client.pyreverted toorigin/develop, to show they fail for the real defect and not by construction:origin/developtest_create_post_sends_generated_request_id_and_keeps_authorizationx-request-idwas sent at alltest_put_reaches_the_wire_with_a_request_idheaderskwargtest_retried_get_reuses_one_request_id_across_attemptstest_timed_out_post_raises_after_exactly_one_attemptroute.call_count == 1on the wiretest_timed_out_post_error_says_it_was_not_retriedtest_publish_now_create_uses_the_publish_timeoutpublishNowran at 30stest_publish_timeout_constructor_override_reaches_the_wirepublish_timeoutdid not existtest_async_timed_out_post_raises_after_exactly_one_attempttest_async_create_sends_generated_request_idtest_caller_supplied_request_id_is_not_overwrittensetdefaultover[...] =test_connect_error_on_post_still_retriestest_plain_create_uses_the_default_timeoutThe three that pass both ways are deliberate guards, not tests that pass for the wrong reason.
test_put_reaches_the_wire_with_a_request_idwas additionally verified by patchingkwargs.get("headers")tokwargs["headers"]and re-running: it fails with aKeyErrorand nothing else in the file does. It is the only pin on that distinction.Other checks on this branch:
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 mcpfirst or pytest collects a smaller set and the numbers will not line up.Targeting
developDeliberately, not
main. The auto-regen workflow publishes the wheel fromdevelop, andCHANGELOG.md [1.4.49]documents what happens otherwise: the_patchfix landed onmainonly and develop's next regen republished the wheel without it.Known gaps, deliberately out of scope
timeout=<float>in httpx overrides all four components, not justread. So apublishNowcreate 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}withpublishNowhas 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.media.*,upload/directandposts.bulk_uploadare 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.posts_publish_nowcall 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.LateAPIErroris not caught by either retry loop, so the timeout branch was effectively the entire retry surface. Pre-existing, untouched here.$refheader-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:1001vs: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, userId6a79041e4812f4a30ffbbe69.Post A
6a9894c6799b0c3b2e53edf4-> Threads17972389461067916.Duplicate B
6a989578eb3b03d24b29bb36->18113507188969526, deleted by the customer.🤖 Generated with Claude Code
https://claude.ai/code/session_01QyoePpXUo8PHpGUr9JTu23