fix(idempotency): send canonical Idempotency-Key header (1.14.1)#64
Merged
Conversation
`_raw_request` was sending `X-Idempotency-Key` on retries, which the
server's `IdempotencyMiddleware` silently ignored — it accepts only the
bare canonical name. Net effect: every SDK caller that thought they had
safe retries was actually producing duplicate writes.
Empirical reproduction from colonist-one on 2026-06-03: same key, same
body, two POSTs to /messages/send/{username} → two distinct rows.
Changes:
- Rename outgoing request header `X-Idempotency-Key` → `Idempotency-Key`
in both `ColonyClient._raw_request` and `AsyncColonyClient._raw_request`.
- Add `idempotency_key` kwarg to `ColonyClient.send_message` and to
`AsyncColonyClient.send_message` (was missing; only the group send had
it). The async `_raw_request` previously didn't accept the kwarg at all.
- Sync 401-refresh and 429-retry paths now thread the key through
(previously dropped).
- `mark_conversation_spam` (sync + async) now reads BOTH
`Idempotent-Replay` (canonical, matches the middleware) and the legacy
`X-Idempotency-Replayed` during the server-side migration grace window
(60 days). Preserves the upstream forward-compat path: body-field
`idempotency_replayed` still wins over the header read.
- New module-level `generate_idempotency_key() -> str` helper returns a
UUIDv4 hex so callers don't need to import `uuid`.
- `MockColonyClient.send_message` mirrors the new signature.
- Regression pins:
- Assert outgoing header is `Idempotency-Key`, not `X-Idempotency-Key`,
on sync + async send and on the generic `_raw_request`.
- Assert the X-prefixed form never gets emitted (sync `test_advanced`
+ `test_api_methods` group-send + new async tests).
- Assert canonical `Idempotent-Replay` and legacy
`X-Idempotency-Replayed` are both honoured on the spam replay path,
sync + async.
- Assert `idempotency_key` survives a 429 retry through the async
transport.
- Assert `generate_idempotency_key()` returns UUIDv4 hex.
Patch release (1.14.0 → 1.14.1) — no breaking changes.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Two whitespace-only fixes the CI formatter wanted (blank lines after the `import uuid` inside `generate_idempotency_key` and after a docstring in the async grace-period test).
The idempotency header-rename fix ships in 1.14.1 (per pyproject + CHANGELOG), but six inline comments/docstrings referred to it as 1.14.0. Comment-only; no behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator
|
Reviewed and verified. ✅ Live dedup check (the unchecked test-plan box) — PASS. Ran two
So the canonical Follow-up commit pushed ( LGTM. |
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
_raw_requestwas sendingX-Idempotency-Keyon retries, which the server'sIdempotencyMiddlewaresilently ignored — it accepts only the bare canonicalIdempotency-Keyname. Net effect: every SDK caller that thought they had safe retries was actually producing duplicate writes.Empirical reproduction came from a colonist-one DM: same key, same body, two POSTs to
/messages/send/{username}→ two distinct rows in the recipient's conversation. The middleware's 24-hour replay, 409-on-body-mismatch, and 409-on-in-progress semantics simply never engaged.This is a patch release (1.14.0 → 1.14.1). No breaking changes.
Changes
Header rename (the actual fix):
ColonyClient._raw_requestandAsyncColonyClient._raw_requestnow sendIdempotency-Keyinstead ofX-Idempotency-Key.Async transport parity:
_raw_requestpreviously didn't acceptidempotency_keyat all — added.1:1 send parity:
ColonyClient.send_message(...)andAsyncColonyClient.send_message(...)now acceptidempotency_key: str | None = None, matchingsend_group_message. Closes a longstanding asymmetry.Replay-marker reading:
mark_conversation_spam(sync + async) now reads BOTHIdempotent-Replay(canonical, matches the middleware) and the legacyX-Idempotency-Replayedduring the 60-day server-side migration grace window. The forward-compat path that readsidempotency_replayedfrom the JSON body envelope is preserved.New helper:
generate_idempotency_key() -> str— module-level helper returninguuid.uuid4().hex. Optional but nudges callers off bring-your-own-key footguns (length cap, ASCII charset).Mock parity:
MockColonyClient.send_messagemirrors the new signature.Regression pins
Idempotency-Key, notX-Idempotency-Key, on sync + async send and on the generic_raw_request.Idempotent-Replayand legacyX-Idempotency-Replayedflipidempotency_replayed=Trueon the spam path, sync + async.idempotency_keysurvives a 429 retry through the async transport.generate_idempotency_key()returns UUIDv4 hex (32 lowercase hex chars).Test plan
pytest tests/— 709 pass, 147 skipped./messages/send/{username}with the sameIdempotency-Key+ body now produce one message (replay) instead of two.🤖 Generated with Claude Code