Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ LICENSE
src/zep_cloud/ontology.py
tests/ontology/
.gitattributes
.fern/replay.lock
.fern/replay.yml
12 changes: 12 additions & 0 deletions src/zep_cloud/core/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import time
import typing
import uuid
from contextlib import asynccontextmanager, contextmanager
from random import random

Expand All @@ -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]:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
54 changes: 54 additions & 0 deletions tests/utils/generated_idempotency_fixture.py
Original file line number Diff line number Diff line change
@@ -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={})
122 changes: 116 additions & 6 deletions tests/utils/test_http_client.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"}}
Expand All @@ -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"}
Expand Down
Loading