From ac5c3f1d4c55e6996c87fe35e84f30b1b73c62e9 Mon Sep 17 00:00:00 2001 From: arch-colony Date: Wed, 3 Jun 2026 20:12:26 +0100 Subject: [PATCH 1/3] fix(idempotency): send canonical Idempotency-Key header (1.14.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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. --- CHANGELOG.md | 16 +++++++ pyproject.toml | 2 +- src/colony_sdk/__init__.py | 4 +- src/colony_sdk/async_client.py | 76 +++++++++++++++++++++-------- src/colony_sdk/client.py | 88 ++++++++++++++++++++++++++++++---- src/colony_sdk/testing.py | 18 +++++-- tests/test_advanced.py | 29 ++++++++++- tests/test_api_methods.py | 49 +++++++++++++++++-- tests/test_async_client.py | 81 +++++++++++++++++++++++++++++++ 9 files changed, 325 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b5fbd..cb76b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.14.1 — 2026-06-03 + +**Release theme: idempotency bugfix.** A header-name mismatch between the SDK and the server made the `idempotency_key` argument silently a no-op — agents that retried on network errors created duplicate writes. This patch fixes the header names and adds the missing kwarg to the 1:1 send surface so the 1:1 and group endpoints have parity. + +### Bug fixes + +- **`Idempotency-Key` is now sent under the canonical RFC-style name.** Earlier versions sent `X-Idempotency-Key`, which the server's `IdempotencyMiddleware` ignored (the middleware accepts only the bare name). The 24-hour replay, 409-on-body-mismatch, and 409-on-in-progress semantics simply never engaged for SDK callers. Symptom: same key + same body → two distinct messages / posts / votes, rather than a deduped replay. Now fixed across `ColonyClient._raw_request`, `AsyncColonyClient._raw_request`, `send_message`, and `send_group_message`. Both sync 401-refresh and 429-retry paths thread the key through. + +- **`mark_conversation_spam(...)['idempotency_replayed']` now flips correctly on real replays.** The SDK previously read `X-Idempotency-Replayed` from the spam route's response; the server-side migration in flight renames that header to the canonical `Idempotent-Replay`. The SDK now reads either name during the 60-day grace window, so the boolean is correct against both old and new server builds. + +### New (minor surface) + +- **`ColonyClient.send_message(...)` + `AsyncColonyClient.send_message(...)` now accept `idempotency_key: str | None = None`** — was missing from 1.14.x (only the group send surface had it). Matches the same signature shape as `send_group_message`. The async `_raw_request` previously didn't accept or thread the kwarg at all — now it does. + +- **`generate_idempotency_key() -> str`** — module-level helper returning `uuid.uuid4().hex`. Use as a sensible default for the `idempotency_key` argument so callers don't have to import `uuid` themselves. + ## 1.14.0 — 2026-06-03 **Release theme: safety + moderation primitives.** Two PRs bundled — block / unblock / list_blocked / report_* wrappers (PR #62, closing the user-blocking SDK gap that the upstream platform already supported server-side) and the DM-spam reporting surface (PR #63, THECOLONYC-44). 11 new SDK methods total across sync + async + mock, plus a new `last_response_headers` infrastructure attribute. diff --git a/pyproject.toml b/pyproject.toml index 3819951..8b9c964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "colony-sdk" -version = "1.14.0" +version = "1.14.1" description = "Python SDK for The Colony (thecolony.cc) — the official Python client for the AI agent internet" readme = "README.md" license = {text = "MIT"} diff --git a/src/colony_sdk/__init__.py b/src/colony_sdk/__init__.py index 91dd34e..ac17cae 100644 --- a/src/colony_sdk/__init__.py +++ b/src/colony_sdk/__init__.py @@ -34,6 +34,7 @@ async def main(): ColonyServerError, ColonyValidationError, RetryConfig, + generate_idempotency_key, verify_webhook, ) from colony_sdk.colonies import COLONIES @@ -61,7 +62,7 @@ async def main(): from colony_sdk.async_client import AsyncColonyClient from colony_sdk.testing import MockColonyClient -__version__ = "1.14.0" +__version__ = "1.14.1" __all__ = [ "COLONIES", "AsyncColonyClient", @@ -88,6 +89,7 @@ async def main(): "ValidateOk", "ValidateRejected", "Webhook", + "generate_idempotency_key", "looks_like_model_error", "strip_llm_artifacts", "validate_generated_output", diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index c9b6669..0a40312 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -112,7 +112,7 @@ def __init__( # Raw response headers (lowercased keys) from the most recent # request. Mirrors :attr:`ColonyClient.last_response_headers` # so async callers can read per-call header signals like - # ``X-Idempotency-Replayed`` without per-endpoint plumbing. + # ``Idempotent-Replay`` without per-endpoint plumbing. # # Async invariant: read this attribute on the same coroutine, # synchronously after the ``_raw_request`` await returns. The @@ -357,6 +357,7 @@ async def _raw_request( auth: bool = True, _retry: int = 0, _token_refreshed: bool = False, + idempotency_key: str | None = None, ) -> dict: # Circuit breaker — fail fast if too many consecutive failures. if self._circuit_breaker_threshold > 0 and self._consecutive_failures >= self._circuit_breaker_threshold: @@ -381,6 +382,10 @@ async def _raw_request( headers["Content-Type"] = "application/json" if auth and self._token: headers["Authorization"] = f"Bearer {self._token}" + # Idempotency key for POST requests — see + # :meth:`ColonyClient._raw_request` for the header-name note. + if idempotency_key and method == "POST": + headers["Idempotency-Key"] = idempotency_key # Invoke request hooks. for hook in self._on_request: @@ -430,7 +435,15 @@ async def _raw_request( self._clear_cached_token() self._token = None self._token_expiry = 0 - return await self._raw_request(method, path, body, auth, _retry=_retry, _token_refreshed=True) + return await self._raw_request( + method, + path, + body, + auth, + _retry=_retry, + _token_refreshed=True, + idempotency_key=idempotency_key, + ) # Configurable retry on transient failures (429, 502, 503, 504 by default). retry_after_hdr = resp.headers.get("Retry-After") @@ -439,7 +452,13 @@ async def _raw_request( delay = _compute_retry_delay(_retry, self.retry, retry_after_val) await asyncio.sleep(delay) return await self._raw_request( - method, path, body, auth, _retry=_retry + 1, _token_refreshed=_token_refreshed + method, + path, + body, + auth, + _retry=_retry + 1, + _token_refreshed=_token_refreshed, + idempotency_key=idempotency_key, ) self._consecutive_failures += 1 @@ -781,9 +800,22 @@ async def vote_poll( # ── Messaging ──────────────────────────────────────────────────── - async def send_message(self, username: str, body: str) -> dict: - """Send a direct message to another agent.""" - data = await self._raw_request("POST", f"/messages/send/{username}", body={"body": body}) + async def send_message( + self, + username: str, + body: str, + idempotency_key: str | None = None, + ) -> dict: + """Send a direct message to another agent. See + :meth:`ColonyClient.send_message` for the full contract; + ``idempotency_key`` threads through to the + ``Idempotency-Key`` header for safe retries.""" + data = await self._raw_request( + "POST", + f"/messages/send/{username}", + body={"body": body}, + idempotency_key=idempotency_key, + ) return self._wrap(data, Message) async def get_conversation(self, username: str) -> dict: @@ -807,7 +839,10 @@ async def mark_conversation_spam( docstring there. Returns the server envelope merged with ``idempotency_replayed: bool`` so callers can distinguish first mark (False, 201) from idempotent re-mark - (True, 200 + ``X-Idempotency-Replayed: true``). + (True, 200 + ``Idempotent-Replay: true``). The SDK accepts + both ``Idempotent-Replay`` and the legacy + ``X-Idempotency-Replayed`` during the server-side grace + window. """ body: dict[str, Any] = {"reason_code": reason_code} if description is not None: @@ -822,7 +857,15 @@ async def mark_conversation_spam( # rather than silently clobbering with the header-derived value. if "idempotency_replayed" in data: return data - replayed = self.last_response_headers.get("x-idempotency-replayed", "").lower() == "true" + # Canonical name is ``Idempotent-Replay``; the spam route still + # emits the legacy ``X-Idempotency-Replayed`` during the + # server-side migration grace window. Accept either so old + + # new server builds both work. + replay_headers = self.last_response_headers + replayed = ( + replay_headers.get("idempotent-replay", "").lower() == "true" + or replay_headers.get("x-idempotency-replayed", "").lower() == "true" + ) return {**data, "idempotency_replayed": replayed} async def unmark_conversation_spam(self, username: str) -> dict: @@ -894,18 +937,12 @@ async def send_group_message( conv_id: str, body: str, reply_to_message_id: str | None = None, + idempotency_key: str | None = None, ) -> dict: - """Send a message to a group conversation. - - Note: the async client's :meth:`_raw_request` does not yet - thread the ``Idempotency-Key`` header through. Callers that - need at-least-once delivery should use the sync - :class:`ColonyClient.send_group_message` until the async path - gains parity (the gap matches the existing async - ``send_message`` — adding idempotency-key threading to the - async transport is tracked separately so the 1:1 and group - surfaces move together). - """ + """Send a message to a group conversation. See + :meth:`ColonyClient.send_group_message` for the full contract; + ``idempotency_key`` threads through to the + ``Idempotency-Key`` header for safe retries.""" body_payload: dict[str, object] = {"body": body} if reply_to_message_id is not None: body_payload["reply_to_message_id"] = reply_to_message_id @@ -913,6 +950,7 @@ async def send_group_message( "POST", f"/messages/groups/{conv_id}/send", body=body_payload, + idempotency_key=idempotency_key, ) return self._wrap(data, Message) diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index a74c8c3..d1c1a44 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -111,6 +111,33 @@ def verify_webhook(payload: bytes | str, signature: str, secret: str) -> bool: return hmac.compare_digest(expected, received) +def generate_idempotency_key() -> str: + """Return a fresh UUID v4 hex string suitable for use as an + ``Idempotency-Key`` header value. + + Every Colony write that accepts an idempotency key wants a unique, + opaque ASCII string up to 255 chars. A v4 UUID's hex form is 32 + chars, easily within the limit, has no padding ambiguity, and is + safe to log. Reuse the same key on retries of the **same logical + write**; never reuse across different writes. + + Example:: + + from colony_sdk import ColonyClient, generate_idempotency_key + + client = ColonyClient("col_...") + key = generate_idempotency_key() + for attempt in range(3): + try: + msg = client.send_message("alice", "hi", idempotency_key=key) + break + except ColonyNetworkError: + continue # safe retry — same key, no duplicate + """ + import uuid + return uuid.uuid4().hex + + @dataclass(frozen=True) class RetryConfig: """Configuration for transient-error retries. @@ -849,8 +876,11 @@ def _raw_request( if auth and self._token: headers["Authorization"] = f"Bearer {self._token}" # Idempotency key for POST requests to prevent duplicate creates on retries. + # The server reads the canonical `Idempotency-Key` header (no `X-` prefix); + # earlier SDK versions sent `X-Idempotency-Key`, which the middleware silently + # ignored — duplicates wrote through. Fixed in 1.14.0. if idempotency_key and method == "POST": - headers["X-Idempotency-Key"] = idempotency_key + headers["Idempotency-Key"] = idempotency_key # Invoke request hooks. for hook in self._on_request: @@ -913,6 +943,7 @@ def _raw_request( auth, _retry=_retry, _token_refreshed=True, + idempotency_key=idempotency_key, retry_override=retry_override, ) @@ -934,6 +965,7 @@ def _raw_request( auth, _retry=_retry + 1, _token_refreshed=_token_refreshed, + idempotency_key=idempotency_key, retry_override=retry_override, ) @@ -1634,9 +1666,31 @@ def vote_poll( # ── Messaging ──────────────────────────────────────────────────── - def send_message(self, username: str, body: str) -> dict: - """Send a direct message to another agent.""" - data = self._raw_request("POST", f"/messages/send/{username}", body={"body": body}) + def send_message( + self, + username: str, + body: str, + idempotency_key: str | None = None, + ) -> dict: + """Send a direct message to another agent. + + Args: + username: Recipient username (case-insensitive). + body: Message text. Markdown is rendered server-side. + idempotency_key: Optional ``Idempotency-Key`` header + value. When set, retrying with the same key + body + returns the originally-stored message rather than + creating a duplicate row. Useful for at-least-once + delivery loops; a UUIDv4 per logical send is the + recommended default — see + :func:`colony_sdk.generate_idempotency_key`. + """ + data = self._raw_request( + "POST", + f"/messages/send/{username}", + body={"body": body}, + idempotency_key=idempotency_key, + ) return self._wrap(data, Message) def get_conversation(self, username: str) -> dict: @@ -1680,10 +1734,18 @@ def mark_conversation_spam( ``report_id``) merged with one SDK-side field: ``idempotency_replayed`` — ``True`` when this call was a no-op re-mark (the API returns 200 + - ``X-Idempotency-Replayed: true`` instead of inserting - a duplicate audit row), ``False`` on first mark - (201). Use this to distinguish "first time you've - reported them" from "already had a pending report". + ``Idempotent-Replay: true`` instead of inserting a + duplicate audit row), ``False`` on first mark (201). + Use this to distinguish "first time you've reported + them" from "already had a pending report". + + *Header-name compatibility note (SDK 1.14+):* the SDK + reads both the canonical ``Idempotent-Replay`` and + the legacy ``X-Idempotency-Replayed`` response headers + so it stays correct across the 60-day server-side + grace window. Older SDK versions only read the legacy + name and will return ``False`` once the server drops + it. Raises: ColonyValidationError: 400 — target was a group @@ -1707,7 +1769,15 @@ def mark_conversation_spam( # The header path is a fill-in for the current shape only. if "idempotency_replayed" in data: return data - replayed = self.last_response_headers.get("x-idempotency-replayed", "").lower() == "true" + # Canonical name is ``Idempotent-Replay``; the spam route still + # emits the legacy ``X-Idempotency-Replayed`` during the + # server-side migration grace window. Accept either so old + + # new server builds both work. + replay_headers = self.last_response_headers + replayed = ( + replay_headers.get("idempotent-replay", "").lower() == "true" + or replay_headers.get("x-idempotency-replayed", "").lower() == "true" + ) return {**data, "idempotency_replayed": replayed} def unmark_conversation_spam(self, username: str) -> dict: diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 977770b..dd04028 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -208,8 +208,20 @@ def vote_poll(self, post_id: str, option_ids: list[str] | None = None, **kwargs: # ── Messaging ── - def send_message(self, username: str, body: str) -> dict: - return self._respond("send_message", {"username": username, "body": body}) + def send_message( + self, + username: str, + body: str, + idempotency_key: str | None = None, + ) -> dict: + return self._respond( + "send_message", + { + "username": username, + "body": body, + "idempotency_key": idempotency_key, + }, + ) def get_conversation(self, username: str) -> dict: return self._respond("get_conversation", {"username": username}) @@ -275,7 +287,7 @@ def send_group_message( idempotency_key: str | None = None, ) -> dict: # Mirror the sync ColonyClient signature exactly. The async - # counterpart drops idempotency_key (gap documented there). + # counterpart now also accepts idempotency_key (fixed 1.14.0). return self._respond( "send_group_message", { diff --git a/tests/test_advanced.py b/tests/test_advanced.py index c4d970d..14548e4 100644 --- a/tests/test_advanced.py +++ b/tests/test_advanced.py @@ -307,6 +307,25 @@ def side_effect(*args, **kwargs): # ── Idempotency ────────────────────────────────────────────────────── +class TestGenerateIdempotencyKey: + def test_returns_uuid4_hex(self) -> None: + from colony_sdk import generate_idempotency_key + + key = generate_idempotency_key() + assert isinstance(key, str) + assert len(key) == 32 + # UUIDv4 hex is lowercase 0-9a-f + assert all(c in "0123456789abcdef" for c in key) + + def test_distinct_keys_on_repeated_calls(self) -> None: + from colony_sdk import generate_idempotency_key + + keys = {generate_idempotency_key() for _ in range(100)} + # UUIDv4 collision probability over 100 draws is ~10⁻³⁵; any + # collision here would mean the impl is broken. + assert len(keys) == 100 + + class TestIdempotency: def test_idempotency_key_sent_on_post(self) -> None: client = _make_client() @@ -319,7 +338,14 @@ def capture_urlopen(req, **kwargs): with patch("colony_sdk.client.urlopen", side_effect=capture_urlopen): client._raw_request("POST", "/posts", body={"title": "T"}, idempotency_key="key-123") - assert captured_headers.get("X-idempotency-key") == "key-123" + # urllib normalises header names to title-case-with-rest-lowercase. + # The canonical header is ``Idempotency-Key`` (NOT ``X-Idempotency-Key``; + # see 1.14.0 changelog — the older ``X-`` form was the cause of + # silently-not-deduped duplicate writes). + assert captured_headers.get("Idempotency-key") == "key-123" + # Hard-pin the regression so a future PR can't quietly reintroduce the + # X- prefix without deliberately deleting this line. + assert "X-idempotency-key" not in captured_headers def test_idempotency_key_not_sent_on_get(self) -> None: client = _make_client() @@ -332,6 +358,7 @@ def capture_urlopen(req, **kwargs): with patch("colony_sdk.client.urlopen", side_effect=capture_urlopen): client._raw_request("GET", "/users/me", idempotency_key="key-123") + assert "Idempotency-key" not in captured_headers assert "X-idempotency-key" not in captured_headers diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 7f6056d..0af7ec2 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -792,6 +792,24 @@ def test_send_message(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "POST" assert req.full_url == f"{BASE}/messages/send/alice" assert _last_body(mock_urlopen) == {"body": "Hello!"} + # No idempotency header unless explicitly requested. + assert req.headers.get("Idempotency-key") is None + + @patch("colony_sdk.client.urlopen") + def test_send_message_with_idempotency_key(self, mock_urlopen: MagicMock) -> None: + """1.14.0 SDK threads the ``Idempotency-Key`` header through + the 1:1 send surface, matching ``send_group_message``.""" + mock_urlopen.return_value = _mock_response({"id": "msg-1"}) + client = _authed_client() + + client.send_message("alice", "Hello!", idempotency_key="dm-key-abc") + + req = _last_request(mock_urlopen) + # urllib normalises header names to title-case-with-rest-lowercase. + assert req.headers.get("Idempotency-key") == "dm-key-abc" + # The old X- form must never come back — regression pin for the + # bug that was silently producing duplicate DMs. + assert "X-idempotency-key" not in req.headers @patch("colony_sdk.client.urlopen") def test_get_conversation(self, mock_urlopen: MagicMock) -> None: @@ -2455,8 +2473,10 @@ def test_send_group_message_minimal(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "POST" assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/send" assert _last_body(mock_urlopen) == {"body": "Hi team"} - # No X-Idempotency-Key header unless explicitly set. + # No Idempotency-Key header unless explicitly set. # urllib normalises header names to title-case-with-rest-lowercase. + assert req.headers.get("Idempotency-key") is None + # Pin the X- form to never be emitted again — see 1.14.0 notes. assert req.headers.get("X-idempotency-key") is None @patch("colony_sdk.client.urlopen") @@ -2478,7 +2498,8 @@ def test_send_group_message_with_idempotency_key(self, mock_urlopen: MagicMock) req = _last_request(mock_urlopen) # urllib normalises header names to title-case-with-rest-lowercase. - assert req.headers.get("X-idempotency-key") == "abc-123" + assert req.headers.get("Idempotency-key") == "abc-123" + assert "X-idempotency-key" not in req.headers class TestGroupMembership: @@ -3168,7 +3189,7 @@ def test_bytes_request_fires_request_and_response_hooks(self, mock_urlopen: Magi def _mock_response_with_headers(data: dict, headers: dict[str, str], status: int = 200) -> MagicMock: """Variant of ``_mock_response`` that exposes specific response headers via ``getheaders()`` so per-call header signals like - ``X-Idempotency-Replayed`` are reachable by the SDK code under + ``Idempotent-Replay`` are reachable by the SDK code under test. The default ``_mock_response`` relies on MagicMock's iter-as-empty default for ``getheaders()`` which is the right shape only when callers ignore headers.""" @@ -3218,7 +3239,7 @@ def test_mark_idempotent_replay_sets_flag(self, mock_urlopen: MagicMock) -> None "spam_reason_code": "spam", "report_id": "r1", }, - headers={"X-Idempotency-Replayed": "true"}, + headers={"Idempotent-Replay": "true"}, status=200, ) client = _authed_client() @@ -3250,6 +3271,26 @@ def test_mark_server_body_field_takes_precedence_over_header(self, mock_urlopen: # Body wins — header-derived path is a fill-in only. assert result["idempotency_replayed"] is True + @patch("colony_sdk.client.urlopen") + def test_mark_idempotent_replay_accepts_canonical_header(self, mock_urlopen: MagicMock) -> None: + """1.14.1 reads the canonical ``Idempotent-Replay`` header — the + server-side spam-route migration emits this once the grace + window ends. Pin so future SDK changes can't quietly stop + recognising it.""" + mock_urlopen.return_value = _mock_response_with_headers( + { + "conversation_id": "c1", + "spam_reported_at": "2026-06-03T16:00:00Z", + "spam_reason_code": "spam", + "report_id": "r1", + }, + headers={"Idempotent-Replay": "true"}, + status=200, + ) + client = _authed_client() + result = client.mark_conversation_spam("alice") + assert result["idempotency_replayed"] is True + @patch("colony_sdk.client.urlopen") def test_mark_default_reason_is_spam(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response_with_headers( diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 20bc57f..f5d672b 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -2809,6 +2809,26 @@ def handler(request: httpx.Request) -> httpx.Response: assert result["report_id"] == "r1" async def test_mark_idempotent_replay_sets_flag(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"Idempotent-Replay": "true"}, + content=json.dumps( + { + "conversation_id": "c1", + "spam_reported_at": "2026-06-03T16:00:00Z", + "spam_reason_code": "spam", + "report_id": "r1", + } + ).encode(), + ) + + client = _make_client(handler) + result = await client.mark_conversation_spam("alice") + assert result["idempotency_replayed"] is True + + async def test_mark_idempotent_replay_accepts_legacy_header(self) -> None: + """Grace-period pin — see sync sibling for rationale.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, @@ -2937,3 +2957,64 @@ def handler(request: httpx.Request) -> httpx.Response: await client._raw_request("GET", "/whatever", auth=False) assert client.last_response_headers["x-idempotency-replayed"] == "true" assert client.last_response_headers["x-custom"] == "x" + + +class TestAsyncIdempotencyKeyHeader: + """Regression pins for the 1.14.0 SDK fix that renamed the + outgoing request header from ``X-Idempotency-Key`` to the + canonical ``Idempotency-Key`` (the X- form was silently + ignored by the server middleware → duplicate writes).""" + + async def test_send_message_passes_canonical_header(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["headers"] = dict(request.headers) + return _json_response({"id": "m1", "body": "hi"}, status=201) + + client = _make_client(handler) + await client.send_message("alice", "hi", idempotency_key="key-async-1") + # httpx lower-cases header keys on the request side. + assert seen["headers"].get("idempotency-key") == "key-async-1" + assert "x-idempotency-key" not in seen["headers"] + + async def test_send_group_message_passes_canonical_header(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["headers"] = dict(request.headers) + return _json_response({"id": "m1", "body": "hi"}, status=201) + + client = _make_client(handler) + await client.send_group_message("conv-1", "hi", idempotency_key="key-async-g") + assert seen["headers"].get("idempotency-key") == "key-async-g" + assert "x-idempotency-key" not in seen["headers"] + + async def test_no_header_when_idempotency_key_omitted(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["headers"] = dict(request.headers) + return _json_response({"id": "m1", "body": "hi"}, status=201) + + client = _make_client(handler) + await client.send_message("alice", "hi") + assert "idempotency-key" not in seen["headers"] + assert "x-idempotency-key" not in seen["headers"] + + async def test_idempotency_key_survives_429_retry(self) -> None: + """A transient 429 must not strip the key on retry — otherwise + the second attempt creates a duplicate row.""" + calls: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append({"headers": dict(request.headers), "url": str(request.url)}) + if len(calls) == 1: + return httpx.Response(429, headers={"Retry-After": "0"}, content=b"{}") + return _json_response({"id": "m1", "body": "hi"}, status=201) + + client = _make_client(handler) + await client.send_message("alice", "hi", idempotency_key="retry-survive-key") + assert len(calls) == 2 + assert calls[0]["headers"].get("idempotency-key") == "retry-survive-key" + assert calls[1]["headers"].get("idempotency-key") == "retry-survive-key" From 1c5abe46f5637dad5b1e01dc0a9b11c5208dddc8 Mon Sep 17 00:00:00 2001 From: arch-colony Date: Wed, 3 Jun 2026 20:21:05 +0100 Subject: [PATCH 2/3] style: ruff format 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). --- src/colony_sdk/client.py | 1 + tests/test_async_client.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index d1c1a44..24960f3 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -135,6 +135,7 @@ def generate_idempotency_key() -> str: continue # safe retry — same key, no duplicate """ import uuid + return uuid.uuid4().hex diff --git a/tests/test_async_client.py b/tests/test_async_client.py index f5d672b..a7f3af1 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -2829,6 +2829,7 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_mark_idempotent_replay_accepts_legacy_header(self) -> None: """Grace-period pin — see sync sibling for rationale.""" + def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, From e6b75d6b680e23653f6ec8dba1ad3fac11051166 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Wed, 3 Jun 2026 20:43:04 +0100 Subject: [PATCH 3/3] docs: correct version label 1.14.0 -> 1.14.1 in fix comments 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) --- src/colony_sdk/client.py | 2 +- src/colony_sdk/testing.py | 2 +- tests/test_advanced.py | 2 +- tests/test_api_methods.py | 4 ++-- tests/test_async_client.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 24960f3..34abd39 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -879,7 +879,7 @@ def _raw_request( # Idempotency key for POST requests to prevent duplicate creates on retries. # The server reads the canonical `Idempotency-Key` header (no `X-` prefix); # earlier SDK versions sent `X-Idempotency-Key`, which the middleware silently - # ignored — duplicates wrote through. Fixed in 1.14.0. + # ignored — duplicates wrote through. Fixed in 1.14.1. if idempotency_key and method == "POST": headers["Idempotency-Key"] = idempotency_key diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index dd04028..6824310 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -287,7 +287,7 @@ def send_group_message( idempotency_key: str | None = None, ) -> dict: # Mirror the sync ColonyClient signature exactly. The async - # counterpart now also accepts idempotency_key (fixed 1.14.0). + # counterpart now also accepts idempotency_key (fixed 1.14.1). return self._respond( "send_group_message", { diff --git a/tests/test_advanced.py b/tests/test_advanced.py index 14548e4..6e6ce88 100644 --- a/tests/test_advanced.py +++ b/tests/test_advanced.py @@ -340,7 +340,7 @@ def capture_urlopen(req, **kwargs): # urllib normalises header names to title-case-with-rest-lowercase. # The canonical header is ``Idempotency-Key`` (NOT ``X-Idempotency-Key``; - # see 1.14.0 changelog — the older ``X-`` form was the cause of + # see 1.14.1 changelog — the older ``X-`` form was the cause of # silently-not-deduped duplicate writes). assert captured_headers.get("Idempotency-key") == "key-123" # Hard-pin the regression so a future PR can't quietly reintroduce the diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 0af7ec2..068697f 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -797,7 +797,7 @@ def test_send_message(self, mock_urlopen: MagicMock) -> None: @patch("colony_sdk.client.urlopen") def test_send_message_with_idempotency_key(self, mock_urlopen: MagicMock) -> None: - """1.14.0 SDK threads the ``Idempotency-Key`` header through + """1.14.1 SDK threads the ``Idempotency-Key`` header through the 1:1 send surface, matching ``send_group_message``.""" mock_urlopen.return_value = _mock_response({"id": "msg-1"}) client = _authed_client() @@ -2476,7 +2476,7 @@ def test_send_group_message_minimal(self, mock_urlopen: MagicMock) -> None: # No Idempotency-Key header unless explicitly set. # urllib normalises header names to title-case-with-rest-lowercase. assert req.headers.get("Idempotency-key") is None - # Pin the X- form to never be emitted again — see 1.14.0 notes. + # Pin the X- form to never be emitted again — see 1.14.1 notes. assert req.headers.get("X-idempotency-key") is None @patch("colony_sdk.client.urlopen") diff --git a/tests/test_async_client.py b/tests/test_async_client.py index a7f3af1..b3f68c6 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -2961,7 +2961,7 @@ def handler(request: httpx.Request) -> httpx.Response: class TestAsyncIdempotencyKeyHeader: - """Regression pins for the 1.14.0 SDK fix that renamed the + """Regression pins for the 1.14.1 SDK fix that renamed the outgoing request header from ``X-Idempotency-Key`` to the canonical ``Idempotency-Key`` (the X- form was silently ignored by the server middleware → duplicate writes)."""