Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,11 @@ Python 3.9 or higher.
## Contributing

See [the contributing documentation](./CONTRIBUTING.md).

<!-- @custom start -->
### Automatic retry identity

Client-generated idempotency keys use the API's 32-character UUIDv7 format.
Automatic retries reuse the original key. Independent submissions receive new
keys, and callers can still supply an explicit key when retrying a saved request.
<!-- @custom end -->
3 changes: 2 additions & 1 deletion src/dedalus_sdk/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,8 @@ def _should_retry(self, response: httpx.Response) -> bool:
return False

def _idempotency_key(self) -> str:
return f"stainless-python-retry-{uuid.uuid4()}"
# Use the API's UUIDv7 format. Automatic retries reuse this value.
return f"{time.time_ns() // 1_000_000:012x}7{uuid.uuid4().hex[13:]}"


class _DefaultHttpxClient(httpx.Client):
Expand Down
5 changes: 3 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import gc
import os
import re
import sys
import json
import asyncio
Expand Down Expand Up @@ -715,7 +716,7 @@ def test_idempotency_header_options(self, respx_mock: MockRouter, client: Dedalu

header = response.request.headers.get("Idempotency-Key")
assert header is not None
assert header.startswith("stainless-python-retry")
assert re.fullmatch(r"[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}", header)

# explicit header
response = client.post(
Expand Down Expand Up @@ -1674,7 +1675,7 @@ async def test_idempotency_header_options(self, respx_mock: MockRouter, async_cl

header = response.request.headers.get("Idempotency-Key")
assert header is not None
assert header.startswith("stainless-python-retry")
assert re.fullmatch(r"[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}", header)

# explicit header
response = await async_client.post(
Expand Down
51 changes: 51 additions & 0 deletions tests/test_feedback_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Verify automatic retry keys through the installed SDK's real request builder."""

from __future__ import annotations

import re

import httpx
import pytest

from dedalus_sdk import Dedalus, AsyncDedalus


def transport(keys: list[str]) -> httpx.MockTransport:
def handle(request: httpx.Request) -> httpx.Response:
key = request.headers["Idempotency-Key"]
assert re.fullmatch(r"[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}", key)
keys.append(key)
if len(keys) == 1:
return httpx.Response(503, json={}, headers={"retry-after-ms": "1"})
return httpx.Response(201, json={"id": "fb_" + key})

return httpx.MockTransport(handle)


def verify(keys: list[str]) -> None:
assert len(keys) == 3
assert keys[0] == keys[1]
assert keys[1] != keys[2]


@pytest.mark.asyncio
async def test_invariant_async_retry_preserves_valid_feedback_identity() -> None:
keys: list[str] = []
http_client = httpx.AsyncClient(transport=transport(keys))
async with AsyncDedalus(
base_url="https://feedback.invalid", api_key="fixture", max_retries=1, http_client=http_client
) as client:
await client.post("/v1/feedback", cast_to=httpx.Response, body={"message": "fixture", "source": "cli"})
await client.post("/v1/feedback", cast_to=httpx.Response, body={"message": "new submission", "source": "cli"})
verify(keys)


def test_invariant_sync_retry_preserves_valid_feedback_identity() -> None:
keys: list[str] = []
http_client = httpx.Client(transport=transport(keys))
with Dedalus(
base_url="https://feedback.invalid", api_key="fixture", max_retries=1, http_client=http_client
) as client:
client.post("/v1/feedback", cast_to=httpx.Response, body={"message": "fixture", "source": "cli"})
client.post("/v1/feedback", cast_to=httpx.Response, body={"message": "new submission", "source": "cli"})
verify(keys)
Loading