From fb9002f049363a6ca86f0bde00b84444692bb407 Mon Sep 17 00:00:00 2001 From: smaramwbc <145447586+smaramwbc@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:21:42 +0100 Subject: [PATCH] feat: optional idempotency_key on create_episode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the server's idempotency_key as a first-class parameter on create_episode (sync + async). Re-ingesting an episode with the same key (re-running a backfill, retrying a failed request) is a no-op — the server returns the existing episode instead of inserting a duplicate. create_episodes_batch already forwards the key when present in each episode dict (raw pass-through). Forwarded verbatim, omitted from the wire when unset so the request shape is unchanged for callers that don't use it. Tests cover forward + omit. --- CHANGELOG.md | 4 ++++ statewave/client.py | 14 ++++++++++++++ tests/test_episodes.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e43739..f547ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- `create_episode` (sync + async) accepts an optional `idempotency_key`. Re-ingesting an episode with the same key is a no-op server-side (the server returns the existing episode), so re-running a backfill or retrying a request no longer duplicates episodes. `create_episodes_batch` already forwards the key when present in each episode dict. + ## 1.0.1 (2026-06-11) Metadata-only refresh — no API or behavior changes. Republishes the package so the diff --git a/statewave/client.py b/statewave/client.py index a3bd696..8534f2b 100644 --- a/statewave/client.py +++ b/statewave/client.py @@ -193,6 +193,7 @@ def create_episode( metadata: dict[str, Any] | None = None, provenance: dict[str, Any] | None = None, session_id: str | None = None, + idempotency_key: str | None = None, ) -> Episode: """Record a raw interaction episode. @@ -200,6 +201,10 @@ def create_episode( the server's session-aware ranking uses it to surface active-session content in context bundles. Omit it for one-off events; the server treats absence as "no session pin" rather than auto-assigning. + + Pass ``idempotency_key`` to make re-ingest a no-op: a later episode with + the same key (re-running a backfill, retrying a failed request) returns + the existing episode instead of inserting a duplicate. """ body: dict[str, Any] = { "subject_id": subject_id, @@ -211,6 +216,8 @@ def create_episode( } if session_id is not None: body["session_id"] = session_id + if idempotency_key is not None: + body["idempotency_key"] = idempotency_key return self._request("POST", "/v1/episodes", json=body, model=Episode) def create_episodes_batch( @@ -758,6 +765,7 @@ async def create_episode( metadata: dict[str, Any] | None = None, provenance: dict[str, Any] | None = None, session_id: str | None = None, + idempotency_key: str | None = None, ) -> Episode: """Record a raw interaction episode. @@ -765,6 +773,10 @@ async def create_episode( the server's session-aware ranking uses it to surface active-session content in context bundles. Omit it for one-off events; the server treats absence as "no session pin" rather than auto-assigning. + + Pass ``idempotency_key`` to make re-ingest a no-op: a later episode with + the same key (re-running a backfill, retrying a failed request) returns + the existing episode instead of inserting a duplicate. """ body: dict[str, Any] = { "subject_id": subject_id, @@ -776,6 +788,8 @@ async def create_episode( } if session_id is not None: body["session_id"] = session_id + if idempotency_key is not None: + body["idempotency_key"] = idempotency_key return await self._request("POST", "/v1/episodes", json=body, model=Episode) async def create_episodes_batch( diff --git a/tests/test_episodes.py b/tests/test_episodes.py index 237c440..6f731d6 100644 --- a/tests/test_episodes.py +++ b/tests/test_episodes.py @@ -131,3 +131,36 @@ async def test_async_create_episode_forwards_session_id(): body = mock_req.call_args.kwargs["json"] assert body["session_id"] == "sess-xyz-999" + + +# --------------------------------------------------------------------------- +# idempotency_key forwarding +# --------------------------------------------------------------------------- + + +def test_create_episode_forwards_idempotency_key(): + """idempotency_key is forwarded verbatim so re-ingest de-dups server-side.""" + client = StatewaveClient(retry=NO_RETRY) + mock_req = MagicMock(return_value=_resp(200, _EPISODE_RESPONSE)) + with patch.object(client._http, "request", mock_req): + client.create_episode( + subject_id="subj-1", + source="git", + type="git.commit", + payload={"text": "hi"}, + idempotency_key="git:commit:abc", + ) + body = mock_req.call_args.kwargs["json"] + assert body["idempotency_key"] == "git:commit:abc" + + +def test_create_episode_omits_idempotency_key_when_not_passed(): + """Wire shape stays unchanged when the caller doesn't pass a key.""" + client = StatewaveClient(retry=NO_RETRY) + mock_req = MagicMock(return_value=_resp(200, _EPISODE_RESPONSE)) + with patch.object(client._http, "request", mock_req): + client.create_episode( + subject_id="subj-1", source="chat", type="conversation", payload={"text": "hi"} + ) + body = mock_req.call_args.kwargs["json"] + assert "idempotency_key" not in body