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: