From aa32f7bd21898f50fe80104ca8d225df1e22b4a8 Mon Sep 17 00:00:00 2001 From: Max Zhuk Date: Mon, 24 Aug 2026 21:14:56 +0200 Subject: [PATCH] docs(relay): a repeated idempotency key replays, it does not 409 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway grew a translation layer for `/v1/relay/*` (nautilus v1.7.3) and the published reference was corrected to match what Relay actually does: a second send with an `idempotency_key` that was already accepted returns 202 with the *originally* accepted message — same id, same timestamp — rather than a 409. A retry of a request whose response was never seen therefore needs no special handling; there is no duplicate to tell apart from a success. `test_send_409_duplicate` asserted the old promise. Replaced with `test_send_replays_a_repeated_idempotency_key`, which drives two sends under one key and asserts the same message comes back. Error mapping is not lost with it — `test_send_400_error` and the two 429 tests already cover that, on statuses the API really returns. Docstrings now say what the parameters do: the idempotency semantics above, `limit` clamped at 100 rather than rejected, and `next_cursor` null on the last page — so paginate until `has_more` is False, not until `data` comes back empty. Until v1.7.3 `cursor` and `event_type` were forwarded to an upstream that names them differently and ignores what it does not recognise, so both were silently no-ops and a paginating client re-read page one forever. `Message.status` is documented as always `"accepted"`: it records that Relay took the event, not what each subscriber endpoint did with it. No behaviour change — the request this SDK builds was already the documented one. Co-Authored-By: Claude Opus 5 (1M context) --- src/vernesoft/_resources/relay/_messages.py | 40 ++++++++++++++++-- src/vernesoft/_resources/relay/_types.py | 13 +++++- tests/test_relay.py | 47 +++++++++++++-------- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/vernesoft/_resources/relay/_messages.py b/src/vernesoft/_resources/relay/_messages.py index d05a68c..6affb78 100644 --- a/src/vernesoft/_resources/relay/_messages.py +++ b/src/vernesoft/_resources/relay/_messages.py @@ -22,7 +22,17 @@ def send( idempotency_key: Optional[str] = None, channels: Optional[List[str]] = None, ) -> Message: - """Send an event via the relay service.""" + """Send an event via the relay service. + + Retried once automatically on 429, respecting ``Retry-After``. + + ``idempotency_key`` deduplicates within a 24-hour window. Sending the + same key twice does not create a second event and does not fail: the + second call returns the *originally* accepted message, same ``id`` and + same ``timestamp``. So retrying a request whose response you never saw + needs no special handling — there is no duplicate to tell apart from a + success. + """ body: Dict[str, Any] = {"event_type": event_type, "payload": payload} if idempotency_key is not None: body["idempotency_key"] = idempotency_key @@ -38,7 +48,13 @@ def list( cursor: Optional[str] = None, event_type: Optional[str] = None, ) -> ListMessagesResponse: - """List relay messages with optional filters and cursor pagination.""" + """List relay messages with optional filters and cursor pagination. + + ``limit`` above 100 is clamped to 100 rather than rejected. ``cursor`` + takes a previous response's ``next_cursor``, which is ``None`` on the + last page — so paginate until ``has_more`` is ``False`` rather than + until ``data`` comes back empty. + """ params: Dict[str, Any] = {"limit": limit} if cursor is not None: params["cursor"] = cursor @@ -62,7 +78,17 @@ async def send( idempotency_key: Optional[str] = None, channels: Optional[List[str]] = None, ) -> Message: - """Send an event via the relay service.""" + """Send an event via the relay service. + + Retried once automatically on 429, respecting ``Retry-After``. + + ``idempotency_key`` deduplicates within a 24-hour window. Sending the + same key twice does not create a second event and does not fail: the + second call returns the *originally* accepted message, same ``id`` and + same ``timestamp``. So retrying a request whose response you never saw + needs no special handling — there is no duplicate to tell apart from a + success. + """ body: Dict[str, Any] = {"event_type": event_type, "payload": payload} if idempotency_key is not None: body["idempotency_key"] = idempotency_key @@ -78,7 +104,13 @@ async def list( cursor: Optional[str] = None, event_type: Optional[str] = None, ) -> ListMessagesResponse: - """List relay messages with optional filters and cursor pagination.""" + """List relay messages with optional filters and cursor pagination. + + ``limit`` above 100 is clamped to 100 rather than rejected. ``cursor`` + takes a previous response's ``next_cursor``, which is ``None`` on the + last page — so paginate until ``has_more`` is ``False`` rather than + until ``data`` comes back empty. + """ params: Dict[str, Any] = {"limit": limit} if cursor is not None: params["cursor"] = cursor diff --git a/src/vernesoft/_resources/relay/_types.py b/src/vernesoft/_resources/relay/_types.py index a67f0cf..5c703e9 100644 --- a/src/vernesoft/_resources/relay/_types.py +++ b/src/vernesoft/_resources/relay/_types.py @@ -6,7 +6,12 @@ @dataclass(frozen=True) class Message: - """A relay message (sent event).""" + """A relay message (sent event). + + ``status`` is always ``"accepted"``. It records that Relay took the event, + not what each subscriber endpoint did with it afterwards — per-endpoint + delivery state lives in the Console under Dashboard → Relay. + """ id: str event_type: str @@ -25,7 +30,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "Message": @dataclass(frozen=True) class ListMessagesResponse: - """Paginated list of relay messages.""" + """Paginated list of relay messages. + + ``next_cursor`` is ``None`` on the last page, so paginate until ``has_more`` + is ``False`` rather than until ``data`` comes back empty. + """ data: List[Message] has_more: bool diff --git a/tests/test_relay.py b/tests/test_relay.py index c8e9a1e..a2cf0b2 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -100,25 +100,38 @@ def test_send_400_error(httpx_mock: HTTPXMock, relay: Relay) -> None: assert "event_type" in str(err) -def test_send_409_duplicate(httpx_mock: HTTPXMock, relay: Relay) -> None: - httpx_mock.add_response( - method="POST", - url=f"{_BASE_URL}/v1/relay/messages", - status_code=409, - json={ - "error": { - "code": "duplicate_idempotency_key", - "message": "Event already processed.", - "request_id": "req_dup", - } - }, - ) +def test_send_replays_a_repeated_idempotency_key( + httpx_mock: HTTPXMock, relay: Relay +) -> None: + """A repeated idempotency key replays; it does not fail. + + This asserted a 409 until the gateway grew a translation layer for + ``/v1/relay/*`` and the reference was corrected to match what Relay actually + does: the second send returns 202 with the *originally* accepted message, + same id and same timestamp. Nothing here has to tell a duplicate apart from + a success, which is the point. + + Error mapping is still covered — on statuses the API really returns — by + ``test_send_400_error`` and the 429 tests. + """ + for _ in range(2): + httpx_mock.add_response( + method="POST", + url=f"{_BASE_URL}/v1/relay/messages", + status_code=202, + json=_MSG_PAYLOAD, + ) - with pytest.raises(VerneAPIError) as exc_info: - relay.messages.send(event_type="user.created", payload={}, idempotency_key="dup_key") + first = relay.messages.send( + event_type="user.created", payload={"n": 1}, idempotency_key="dup_key" + ) + second = relay.messages.send( + event_type="user.created", payload={"n": 2}, idempotency_key="dup_key" + ) - assert exc_info.value.status == 409 - assert exc_info.value.code == "duplicate_idempotency_key" + assert second.id == first.id + assert second.timestamp == first.timestamp + assert second.status == "accepted" def test_send_429_retries_and_succeeds(httpx_mock: HTTPXMock, relay: Relay) -> None: