Skip to content

fix(idempotency): send canonical Idempotency-Key header (1.14.1)#64

Merged
jackparnell merged 3 commits into
mainfrom
fix/idempotency-key-canonical-header
Jun 3, 2026
Merged

fix(idempotency): send canonical Idempotency-Key header (1.14.1)#64
jackparnell merged 3 commits into
mainfrom
fix/idempotency-key-canonical-header

Conversation

@arch-colony
Copy link
Copy Markdown
Collaborator

Summary

_raw_request was sending X-Idempotency-Key on retries, which the server's IdempotencyMiddleware silently ignored — it accepts only the bare canonical Idempotency-Key name. 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_request and AsyncColonyClient._raw_request now send Idempotency-Key instead of X-Idempotency-Key.

Async transport parity:

  • The async _raw_request previously didn't accept idempotency_key at all — added.
  • 401-refresh and 429-retry paths now thread the key through (previously dropped on retry).

1:1 send parity:

  • ColonyClient.send_message(...) and AsyncColonyClient.send_message(...) now accept idempotency_key: str | None = None, matching send_group_message. Closes a longstanding asymmetry.

Replay-marker reading:

  • mark_conversation_spam (sync + async) now reads BOTH Idempotent-Replay (canonical, matches the middleware) and the legacy X-Idempotency-Replayed during the 60-day server-side migration grace window. The forward-compat path that reads idempotency_replayed from the JSON body envelope is preserved.

New helper:

  • generate_idempotency_key() -> str — module-level helper returning uuid.uuid4().hex. Optional but nudges callers off bring-your-own-key footguns (length cap, ASCII charset).

Mock parity:

  • MockColonyClient.send_message mirrors the new signature.

Regression pins

  • Outgoing header is Idempotency-Key, not X-Idempotency-Key, on sync + async send and on the generic _raw_request.
  • The X-prefixed form is explicitly pinned absent so a future PR can't silently bring it back.
  • Both Idempotent-Replay and legacy X-Idempotency-Replayed flip idempotency_replayed=True on the spam path, sync + async.
  • idempotency_key survives 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.
  • Verify on a real Colony API instance that two POSTs to /messages/send/{username} with the same Idempotency-Key + body now produce one message (replay) instead of two.

🤖 Generated with Claude Code

`_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.
@jackparnell jackparnell requested a review from ColonistOne June 3, 2026 19:14
@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

arch-colony and others added 2 commits June 3, 2026 20:21
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>
@ColonistOne
Copy link
Copy Markdown
Collaborator

Reviewed and verified. ✅

Live dedup check (the unchecked test-plan box) — PASS. Ran two POST /messages/send/{username} calls with one shared Idempotency-Key + identical body against the live API using this branch's SDK:

  • Both POSTs returned the same message id (0e41feb…) → server replayed instead of inserting.
  • The recipient conversation contains exactly one copy of the body.

So the canonical Idempotency-Key header is genuinely honored server-side — the fix works end-to-end, not just at the header-name layer.

Follow-up commit pushed (e6b75d6): corrected six inline comments/docstrings that labeled this fix 1.14.0; the release is 1.14.1 (per pyproject.toml + CHANGELOG). Comment-only, no behavioral change. All CI green on the new head (lint, typecheck, tests 3.10–3.13).

LGTM.

@jackparnell jackparnell merged commit 855ecaf into main Jun 3, 2026
7 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.

3 participants