From 51a8fbf06bfa80e5b956df7d688f8988852e139e Mon Sep 17 00:00:00 2001 From: Daniel Chalef Date: Thu, 3 Sep 2026 13:25:33 -0700 Subject: [PATCH 1/2] fix: generate mutation idempotency keys --- src/zep_cloud/core/http_client.py | 12 ++ tests/utils/generated_idempotency_fixture.py | 54 ++++++++ tests/utils/test_http_client.py | 122 ++++++++++++++++++- 3 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 tests/utils/generated_idempotency_fixture.py diff --git a/src/zep_cloud/core/http_client.py b/src/zep_cloud/core/http_client.py index ee937589..ddad555e 100644 --- a/src/zep_cloud/core/http_client.py +++ b/src/zep_cloud/core/http_client.py @@ -5,6 +5,7 @@ import re import time import typing +import uuid from contextlib import asynccontextmanager, contextmanager from random import random @@ -21,6 +22,15 @@ INITIAL_RETRY_DELAY_SECONDS = 1.0 MAX_RETRY_DELAY_SECONDS = 60.0 JITTER_FACTOR = 0.2 # 20% random jitter +IDEMPOTENCY_HEADER = "Idempotency-Key" + + +def _fill_idempotency_key( + headers: typing.Optional[typing.Dict[str, typing.Any]], +) -> typing.Optional[typing.Dict[str, typing.Any]]: + if headers is None or IDEMPOTENCY_HEADER not in headers or headers[IDEMPOTENCY_HEADER] is not None: + return headers + return {**headers, IDEMPOTENCY_HEADER: str(uuid.uuid4())} def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: @@ -304,6 +314,7 @@ def request( omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: + headers = _fill_idempotency_key(headers) base_url = self.get_base_url(base_url) timeout = ( request_options.get("timeout_in_seconds") @@ -564,6 +575,7 @@ async def request( omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: + headers = _fill_idempotency_key(headers) base_url = self.get_base_url(base_url) timeout = ( request_options.get("timeout_in_seconds") diff --git a/tests/utils/generated_idempotency_fixture.py b/tests/utils/generated_idempotency_fixture.py new file mode 100644 index 00000000..e40bcad2 --- /dev/null +++ b/tests/utils/generated_idempotency_fixture.py @@ -0,0 +1,54 @@ +"""Minimal generated-client shapes produced by the selective v4 contract.""" + +from typing import Any, Optional + +from zep_cloud.core.http_client import AsyncHttpClient, HttpClient +from zep_cloud.core.request_options import RequestOptions + + +class GeneratedSyncIdempotencyFixture: + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + def mutation( + self, + *, + idempotency_key: Optional[str] = None, + request_options: Optional[RequestOptions] = None, + ) -> Any: + return self._http_client.request( + path="mutation", + method="POST", + headers={"Idempotency-Key": idempotency_key}, + request_options=request_options, + ) + + def get_read(self) -> Any: + return self._http_client.request(path="read", method="GET", headers={}) + + def post_read(self) -> Any: + return self._http_client.request(path="read/search", method="POST", headers={}) + + +class GeneratedAsyncIdempotencyFixture: + def __init__(self, http_client: AsyncHttpClient) -> None: + self._http_client = http_client + + async def mutation( + self, + *, + idempotency_key: Optional[str] = None, + request_options: Optional[RequestOptions] = None, + ) -> Any: + return await self._http_client.request( + path="mutation", + method="POST", + headers={"Idempotency-Key": idempotency_key}, + request_options=request_options, + ) + + async def get_read(self) -> Any: + return await self._http_client.request(path="read", method="GET", headers={}) + + async def post_read(self) -> Any: + return await self._http_client.request(path="read/search", method="POST", headers={}) diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 87b8183d..d0ee93ca 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -1,8 +1,10 @@ # This file was auto-generated by Fern from our API Definition. -from typing import Any, Dict +from typing import Any, Dict, List, Optional, Tuple +from uuid import UUID import pytest +from .generated_idempotency_fixture import GeneratedAsyncIdempotencyFixture, GeneratedSyncIdempotencyFixture from zep_cloud.core.http_client import ( AsyncHttpClient, @@ -18,31 +20,39 @@ class _DummySyncClient: """A minimal stub for httpx.Client that records request arguments.""" - def __init__(self) -> None: + def __init__(self, status_codes: Optional[List[int]] = None) -> None: self.last_request_kwargs: Dict[str, Any] = {} + self.request_kwargs: List[Dict[str, Any]] = [] + self.status_codes = iter(status_codes or [200]) def request(self, **kwargs: Any) -> "_DummyResponse": self.last_request_kwargs = kwargs - return _DummyResponse() + self.request_kwargs.append(kwargs) + return _DummyResponse(next(self.status_codes)) class _DummyAsyncClient: """A minimal stub for httpx.AsyncClient that records request arguments.""" - def __init__(self) -> None: + def __init__(self, status_codes: Optional[List[int]] = None) -> None: self.last_request_kwargs: Dict[str, Any] = {} + self.request_kwargs: List[Dict[str, Any]] = [] + self.status_codes = iter(status_codes or [200]) async def request(self, **kwargs: Any) -> "_DummyResponse": self.last_request_kwargs = kwargs - return _DummyResponse() + self.request_kwargs.append(kwargs) + return _DummyResponse(next(self.status_codes)) class _DummyResponse: """A minimal stub for httpx.Response.""" - status_code = 200 headers: Dict[str, str] = {} + def __init__(self, status_code: int = 200) -> None: + self.status_code = status_code + def get_request_options() -> RequestOptions: return {"additional_body_parameters": {"see you": "later"}} @@ -52,6 +62,106 @@ def get_request_options_with_none() -> RequestOptions: return {"additional_body_parameters": {"see you": "later", "optional": None}} +def _sync_idempotency_fixture( + status_codes: Optional[List[int]] = None, +) -> Tuple[GeneratedSyncIdempotencyFixture, _DummySyncClient]: + dummy_client = _DummySyncClient(status_codes) + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + ) + return GeneratedSyncIdempotencyFixture(http_client), dummy_client + + +def _async_idempotency_fixture( + status_codes: Optional[List[int]] = None, +) -> Tuple[GeneratedAsyncIdempotencyFixture, _DummyAsyncClient]: + dummy_client = _DummyAsyncClient(status_codes) + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + async_base_headers=None, + ) + return GeneratedAsyncIdempotencyFixture(http_client), dummy_client + + +def _assert_uuid4(value: str) -> None: + parsed = UUID(value) + assert parsed.version == 4 + assert str(parsed) == value + + +def test_generated_mutation_gets_uuid4_idempotency_key() -> None: + client, transport = _sync_idempotency_fixture() + + client.mutation() + + _assert_uuid4(transport.last_request_kwargs["headers"]["Idempotency-Key"]) + + +def test_generated_mutation_reuses_idempotency_key_across_retry(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("zep_cloud.core.http_client.time.sleep", lambda _: None) + client, transport = _sync_idempotency_fixture([500, 200]) + + client.mutation(request_options={"max_retries": 1}) + + keys = [request["headers"]["Idempotency-Key"] for request in transport.request_kwargs] + assert len(keys) == 2 + _assert_uuid4(keys[0]) + assert keys[1] == keys[0] + + +def test_generated_mutation_preserves_caller_idempotency_key() -> None: + client, transport = _sync_idempotency_fixture() + + client.mutation(idempotency_key="caller-key") + + assert transport.last_request_kwargs["headers"]["Idempotency-Key"] == "caller-key" + + +def test_generated_get_read_omits_idempotency_key() -> None: + client, transport = _sync_idempotency_fixture() + + client.get_read() + + assert "Idempotency-Key" not in transport.last_request_kwargs["headers"] + + +def test_generated_post_read_omits_idempotency_key() -> None: + client, transport = _sync_idempotency_fixture() + + client.post_read() + + assert "Idempotency-Key" not in transport.last_request_kwargs["headers"] + + +@pytest.mark.asyncio +async def test_async_generated_client_matches_selective_idempotency_contract(monkeypatch: pytest.MonkeyPatch) -> None: + async def no_sleep(_: float) -> None: + return None + + monkeypatch.setattr("zep_cloud.core.http_client.asyncio.sleep", no_sleep) + mutation_client, mutation_transport = _async_idempotency_fixture([500, 200]) + await mutation_client.mutation(request_options={"max_retries": 1}) + mutation_keys = [request["headers"]["Idempotency-Key"] for request in mutation_transport.request_kwargs] + assert len(mutation_keys) == 2 + _assert_uuid4(mutation_keys[0]) + assert mutation_keys[1] == mutation_keys[0] + + caller_client, caller_transport = _async_idempotency_fixture() + await caller_client.mutation(idempotency_key="caller-key") + assert caller_transport.last_request_kwargs["headers"]["Idempotency-Key"] == "caller-key" + + for read in ("get_read", "post_read"): + read_client, read_transport = _async_idempotency_fixture() + await getattr(read_client, read)() + assert "Idempotency-Key" not in read_transport.last_request_kwargs["headers"] + + def test_get_json_request_body() -> None: json_body, data_body = get_request_body(json={"hello": "world"}, data=None, request_options=None, omit=None) assert json_body == {"hello": "world"} From 42e7c294689e0760138e382158c6c55acafa480c Mon Sep 17 00:00:00 2001 From: Daniel Chalef Date: Thu, 3 Sep 2026 14:01:36 -0700 Subject: [PATCH 2/2] chore: preserve Fern Replay state --- .fernignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.fernignore b/.fernignore index ab9b929f..79dc8fdb 100644 --- a/.fernignore +++ b/.fernignore @@ -14,3 +14,5 @@ LICENSE src/zep_cloud/ontology.py tests/ontology/ .gitattributes +.fern/replay.lock +.fern/replay.yml