From 50ece0ce9bf82a3ed834098de210a88e42a30f3a Mon Sep 17 00:00:00 2001 From: Shengming Liang <231182611+AgentWings@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:05:08 -0700 Subject: [PATCH] fix(feedback): generate valid automatic retry keys Signed-off-by: Shengming Liang <231182611+AgentWings@users.noreply.github.com> --- README.md | 8 ++++++ src/dedalus_sdk/_base_client.py | 3 +- tests/test_client.py | 5 ++-- tests/test_feedback_retry.py | 51 +++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/test_feedback_retry.py diff --git a/README.md b/README.md index 0d2e4f5..3f941e6 100644 --- a/README.md +++ b/README.md @@ -466,3 +466,11 @@ Python 3.9 or higher. ## Contributing See [the contributing documentation](./CONTRIBUTING.md). + + +### 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. + diff --git a/src/dedalus_sdk/_base_client.py b/src/dedalus_sdk/_base_client.py index 9507f5b..b01be04 100644 --- a/src/dedalus_sdk/_base_client.py +++ b/src/dedalus_sdk/_base_client.py @@ -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): diff --git a/tests/test_client.py b/tests/test_client.py index c230dbd..ebd5095 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,7 @@ import gc import os +import re import sys import json import asyncio @@ -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( @@ -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( diff --git a/tests/test_feedback_retry.py b/tests/test_feedback_retry.py new file mode 100644 index 0000000..f51bfbe --- /dev/null +++ b/tests/test_feedback_retry.py @@ -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)