Skip to content

Commit fcadb06

Browse files
authored
fix(sdk): align Python response and retry semantics with Anthropic (#8)
1 parent c9a9d4b commit fcadb06

18 files changed

Lines changed: 589 additions & 39 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@ existing `0.1.0` release; earlier development prereleases are not listed.
55

66
## [Unreleased]
77

8+
### Added
9+
10+
- Export `RequestTooLargeError` for HTTP 413 and `OverloadedError` for HTTP 529 from `qca` and `qca.common`, matching Anthropic's Python SDK.
11+
- Add `_strict_response_validation=True` to all four clients to require Pydantic validation of responses, including pagination and SSE. The setting is retained by `with_options` and response views.
12+
813
### Changed
914

15+
- **Breaking:** Responses now use lenient model construction by default, matching Anthropic's Python SDK. Unexpected field types are preserved instead of raising `APIResponseValidationError`; nested models, extra fields, and request IDs remain available. Enable `_strict_response_validation=True` to retain the previous schema validation behavior. Invalid JSON and invalid download URLs still raise in either mode.
16+
- **Breaking:** HTTP 529 now raises `OverloadedError`, which inherits directly from `APIStatusError`, rather than `InternalServerError`. Callers catching `InternalServerError` for overloads should also catch `OverloadedError` or use `APIStatusError`.
17+
- Honor positive `Retry-After` and `Retry-After-Ms` delays above 60 seconds, capped at 4,294,967 seconds. Zero and negative delays use exponential backoff; numeric millisecond headers take precedence. Retry eligibility is unchanged.
1018
- Forward and Managed clients, both synchronous and asynchronous, now default to a 5-second connection timeout and 600-second read, write, and connection-pool timeouts, matching Anthropic's Python SDK. SSE streams can continue beyond 10 minutes while data keeps arriving within the read timeout.
1119

1220
## [0.1.0]

‎CONTRIBUTING.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ The default test command excludes account-backed integration tests and must not
3434

3535
Never commit `.env.live`, tokens, credentials, generated logs, or test output. Integration scenarios must register cleanup immediately after creating a resource. Run them explicitly with `QODER_RUN_LIVE=1`; they are not part of public pull-request CI.
3636

37+
The Forward and Managed integration files also include six `strict_response_contract` checks using a separate client with `_strict_response_validation=True`: model, template/agent, and session lists. They send only GET requests, inspect the first page with `limit=1` where supported, and allow empty lists. An empty list checks the response envelope; item schemas are checked when items exist. Returned items must have a non-empty string ID, and `data` must be present even when empty. Existing business scenarios continue to use the default lenient response parsing.
38+
39+
To run only these read-only checks:
40+
41+
```bash
42+
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE=.env.live uv run pytest tests/integration -m integration -k strict_response_contract -v
43+
```
44+
45+
These checks are included in the existing `test-live`, `test-live-managed`, and `test-live-all` targets. Offline regression tests use the same strict-client fixture and assertions with a mock HTTP transport to verify schema failures without credentials or network access.
46+
3747
## API and contract changes
3848

3949
When adding or changing an endpoint:

‎README.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ Events are also readable after the fact through `client.sessions.events.list(ses
203203

204204
## Handling errors
205205

206-
`APIConnectionError` is raised when the request never reached the API; `APITimeoutError` is its timeout subclass. A non-2xx status raises an `APIStatusError` subclass, and a response that cannot be decoded into its declared type raises `APIResponseValidationError`. All of them derive from `qca.APIError`.
206+
`APIConnectionError` is raised when the request never reached the API; `APITimeoutError` is its timeout subclass. A non-2xx status raises an `APIStatusError` subclass. Invalid JSON, invalid download URLs, and response schema mismatches when strict response validation is enabled raise `APIResponseValidationError`. All of them derive from `qca.APIError`.
207207

208208
```python
209209
from qca import APIConnectionError, APIStatusError, APITimeoutError
@@ -225,13 +225,29 @@ except APIStatusError as exc:
225225
| 403 | `PermissionDeniedError` |
226226
| 404 | `NotFoundError` |
227227
| 409 | `ConflictError` |
228+
| 413 | `RequestTooLargeError` |
228229
| 422 | `UnprocessableEntityError` |
229230
| 429 | `RateLimitError` |
230-
| 5xx | `InternalServerError` |
231+
| 529 | `OverloadedError` |
232+
| other 5xx | `InternalServerError` |
231233
| other | `APIStatusError` |
232234

233235
`code` and `type` are read from the error body and are `None` when the server omits them, so log `message` and `request_id` as well. A non-JSON error body is kept verbatim in `.body`.
234236

237+
`RequestTooLargeError` and `OverloadedError` inherit directly from `APIStatusError`, matching Anthropic. Code that previously caught `InternalServerError` for status 529 should now catch `OverloadedError` as well, or catch `APIStatusError` for all HTTP errors.
238+
239+
## Response validation
240+
241+
By default, responses are constructed into models without requiring every field to match the declared schema, matching Anthropic's Python SDK. Nested objects are still converted to models and unknown fields are retained. Unexpected field values may keep their original type, so type annotations describe the expected API schema rather than guaranteeing the runtime value.
242+
243+
To require Pydantic response validation and raise `APIResponseValidationError` on a schema mismatch, enable the constructor option:
244+
245+
```python
246+
client = Forward(_strict_response_validation=True)
247+
```
248+
249+
The option is available on `Forward`, `Managed`, `AsyncForward`, and `AsyncManaged`, and is preserved by `with_options`, raw and streaming response views, pagination, and SSE parsing. Invalid JSON and invalid download URLs still raise `APIResponseValidationError` in either mode. Direct construction or validation of model classes continues to use Pydantic validation.
250+
235251
## Request IDs
236252

237253
Every response model carries the `x-request-id` of the call that produced it, and errors expose the same value. Include it when reporting a problem.
@@ -245,6 +261,8 @@ print(identity._request_id)
245261

246262
Certain errors are retried twice by default with exponential backoff. GET and HEAD requests, and any request carrying an idempotency key, are retried on connection errors, 408, 429, and 5xx; other requests are retried on 429 only. A 409 is never retried automatically, and an SSE stream that has already been established is never retried. Within those rules the SDK honors `x-should-retry` and a valid `Retry-After-Ms` or `Retry-After`.
247263

264+
Positive server-requested delays, including values over 60 seconds, are honored up to 4,294,967 seconds, matching Anthropic's Python SDK. A numeric `Retry-After-Ms` takes precedence over `Retry-After`, which accepts seconds or an HTTP date. Zero, negative, or invalid delays fall back to exponential backoff. A server-requested wait can therefore exceed the timeout configured for an individual HTTP attempt.
265+
248266
```python
249267
client = Forward(max_retries=0) # disable for all requests
250268
client.with_options(max_retries=5).sessions.list() # or override per call site

‎docs/api/reference.md‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ def __init__(*,
6666
default_headers: Mapping[str, str] | None = None,
6767
default_query: Mapping[str, Any] | None = None,
6868
http_client: httpx.Client | None = None,
69-
credential: Credential | None = None) -> None
69+
credential: Credential | None = None,
70+
_strict_response_validation: bool = False) -> None
7071
```
7172

7273
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_base_client.py)
@@ -143,7 +144,8 @@ def __init__(*,
143144
default_headers: Mapping[str, str] | None = None,
144145
default_query: Mapping[str, Any] | None = None,
145146
http_client: httpx.AsyncClient | None = None,
146-
credential: Credential | AsyncCredential | None = None) -> None
147+
credential: Credential | AsyncCredential | None = None,
148+
_strict_response_validation: bool = False) -> None
147149
```
148150

149151
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_base_client.py)
@@ -356,6 +358,16 @@ class ConflictError(APIStatusError)
356358

357359
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_exceptions.py)
358360

361+
<a id="qca.common._exceptions.RequestTooLargeError"></a>
362+
363+
## RequestTooLargeError
364+
365+
```python
366+
class RequestTooLargeError(APIStatusError)
367+
```
368+
369+
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_exceptions.py)
370+
359371
<a id="qca.common._exceptions.UnprocessableEntityError"></a>
360372

361373
## UnprocessableEntityError
@@ -386,6 +398,16 @@ class InternalServerError(APIStatusError)
386398

387399
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_exceptions.py)
388400

401+
<a id="qca.common._exceptions.OverloadedError"></a>
402+
403+
## OverloadedError
404+
405+
```python
406+
class OverloadedError(APIStatusError)
407+
```
408+
409+
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_exceptions.py)
410+
389411
<a id="qca.common._exceptions.status_error"></a>
390412

391413
#### status\_error
@@ -470,7 +492,11 @@ def to_json(*,
470492
#### parse\_response
471493

472494
```python
473-
def parse_response(cast_to: Any, data: Any, response: Any) -> Any
495+
def parse_response(cast_to: Any,
496+
data: Any,
497+
response: Any,
498+
*,
499+
strict: bool = False) -> Any
474500
```
475501

476502
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_models.py)
@@ -1314,7 +1340,10 @@ class BaseStream(Generic[T])
13141340
#### \_\_init\_\_
13151341

13161342
```python
1317-
def __init__(response: httpx.Response, cast_to: Any) -> None
1343+
def __init__(response: httpx.Response,
1344+
cast_to: Any,
1345+
*,
1346+
strict: bool = False) -> None
13181347
```
13191348

13201349
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)
@@ -1345,7 +1374,10 @@ class Stream(BaseStream[T], Iterator[T])
13451374
#### \_\_init\_\_
13461375

13471376
```python
1348-
def __init__(response: httpx.Response, cast_to: Any) -> None
1377+
def __init__(response: httpx.Response,
1378+
cast_to: Any,
1379+
*,
1380+
strict: bool = False) -> None
13491381
```
13501382

13511383
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)
@@ -1405,7 +1437,10 @@ class AsyncStream(BaseStream[T], AsyncIterator[T])
14051437
#### \_\_init\_\_
14061438

14071439
```python
1408-
def __init__(response: httpx.Response, cast_to: Any) -> None
1440+
def __init__(response: httpx.Response,
1441+
cast_to: Any,
1442+
*,
1443+
strict: bool = False) -> None
14091444
```
14101445

14111446
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)

‎src/qca/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,18 @@
3535
from .common import (
3636
NotGiven as NotGiven,
3737
)
38+
from .common import (
39+
OverloadedError as OverloadedError,
40+
)
3841
from .common import (
3942
PermissionDeniedError as PermissionDeniedError,
4043
)
4144
from .common import (
4245
RateLimitError as RateLimitError,
4346
)
47+
from .common import (
48+
RequestTooLargeError as RequestTooLargeError,
49+
)
4450
from .common import (
4551
UnprocessableEntityError as UnprocessableEntityError,
4652
)

‎src/qca/common/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,18 @@
2828
from ._exceptions import (
2929
NotFoundError as NotFoundError,
3030
)
31+
from ._exceptions import (
32+
OverloadedError as OverloadedError,
33+
)
3134
from ._exceptions import (
3235
PermissionDeniedError as PermissionDeniedError,
3336
)
3437
from ._exceptions import (
3538
RateLimitError as RateLimitError,
3639
)
40+
from ._exceptions import (
41+
RequestTooLargeError as RequestTooLargeError,
42+
)
3743
from ._exceptions import (
3844
UnprocessableEntityError as UnprocessableEntityError,
3945
)

‎src/qca/common/_base_client.py‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import os
55
import random
66
import time
7-
from email.utils import parsedate_to_datetime
7+
from email.utils import mktime_tz, parsedate_tz
88
from typing import Any, Mapping
99

1010
import anyio
@@ -61,6 +61,7 @@ def _configure(
6161
default_headers: Mapping[str, str] | None,
6262
default_query: Mapping[str, Any] | None,
6363
credential: Credential | AsyncCredential | None,
64+
_strict_response_validation: bool,
6465
) -> None:
6566
if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
6667
raise ValueError("max_retries must be a non-negative integer")
@@ -77,6 +78,7 @@ def _configure(
7778
self.max_retries = max_retries
7879
self.default_headers = dict(default_headers or {})
7980
self.default_query = dict(default_query or {})
81+
self._strict_response_validation = _strict_response_validation
8082

8183
def _copy(self, *, raw: bool = False, streaming: bool = False, **overrides: Any) -> Self:
8284
options = dict(
@@ -88,6 +90,7 @@ def _copy(self, *, raw: bool = False, streaming: bool = False, **overrides: Any)
8890
default_query=self.default_query,
8991
credential=self.credential,
9092
http_client=self._client,
93+
_strict_response_validation=self._strict_response_validation,
9194
)
9295
options.update(overrides)
9396
client = type(self)(**options)
@@ -191,19 +194,20 @@ def _retryable(self, request: httpx.Request, response: httpx.Response | None) ->
191194

192195
def _retry_delay(self, retry: int, response: httpx.Response | None) -> float:
193196
if response is not None:
194-
for header, divisor in (("retry-after-ms", 1000), ("retry-after", 1)):
195-
value = response.headers.get(header)
196-
if value is None:
197-
continue
197+
delay = None
198+
try:
199+
delay = float(response.headers["retry-after-ms"]) / 1000
200+
except (KeyError, ValueError):
201+
value = response.headers.get("retry-after", "")
198202
try:
199-
delay = float(value) / divisor
203+
delay = float(value)
200204
except ValueError:
201-
try:
202-
delay = parsedate_to_datetime(value).timestamp() - time.time()
203-
except (ValueError, TypeError, OverflowError):
204-
continue
205-
if 0 <= delay <= 60:
206-
return delay
205+
retry_date = parsedate_tz(value)
206+
if retry_date is not None:
207+
delay = mktime_tz(retry_date) - time.time()
208+
if delay is not None and delay > 0:
209+
# Anthropic caps server-requested waits at the portable sleep limit.
210+
return min(delay, 4_294_967.0)
207211
return min(0.5 * (2 ** min(retry, 10)), 8.0) * (1 - 0.25 * random.random())
208212

209213
def _download_request(self, response: httpx.Response, data: Any) -> httpx.Request:
@@ -237,9 +241,9 @@ def _parse_api_response(
237241
return None
238242
data = self._data(response)
239243
if not page_style:
240-
return parse_response(cast_to, data, response)
244+
return parse_response(cast_to, data, response, strict=self._strict_response_validation)
241245
page_cls = AsyncPage if self._is_async else SyncPage
242-
page = parse_response(page_cls[cast_to], data, response)
246+
page = parse_response(page_cls[cast_to], data, response, strict=self._strict_response_validation)
243247
page._style = page_style
244248
page._query = {**self.default_query, **options.get("query", {})}
245249
client = self._copy() if self._raw_response else self
@@ -261,6 +265,7 @@ def __init__(
261265
default_query: Mapping[str, Any] | None = None,
262266
http_client: httpx.Client | None = None,
263267
credential: Credential | None = None,
268+
_strict_response_validation: bool = False,
264269
) -> None:
265270
self._configure(
266271
pat=pat,
@@ -270,6 +275,7 @@ def __init__(
270275
default_headers=default_headers,
271276
default_query=default_query,
272277
credential=credential,
278+
_strict_response_validation=_strict_response_validation,
273279
)
274280
if http_client is not None and not isinstance(http_client, httpx.Client):
275281
raise TypeError("http_client must be an httpx.Client")
@@ -331,7 +337,7 @@ def request(
331337
request.read()
332338
response = self._send(request)
333339
if stream:
334-
return Stream(response, cast_to)
340+
return Stream(response, cast_to, strict=self._strict_response_validation)
335341
if binary and not download_link:
336342
return BinaryAPIResponse(response)
337343

@@ -374,6 +380,7 @@ def __init__(
374380
default_query: Mapping[str, Any] | None = None,
375381
http_client: httpx.AsyncClient | None = None,
376382
credential: Credential | AsyncCredential | None = None,
383+
_strict_response_validation: bool = False,
377384
) -> None:
378385
self._configure(
379386
pat=pat,
@@ -383,6 +390,7 @@ def __init__(
383390
default_headers=default_headers,
384391
default_query=default_query,
385392
credential=credential,
393+
_strict_response_validation=_strict_response_validation,
386394
)
387395
if http_client is not None and not isinstance(http_client, httpx.AsyncClient):
388396
raise TypeError("http_client must be an httpx.AsyncClient")
@@ -449,7 +457,7 @@ async def request(
449457
await request.aread()
450458
response = await self._send(request)
451459
if stream:
452-
return AsyncStream(response, cast_to)
460+
return AsyncStream(response, cast_to, strict=self._strict_response_validation)
453461
if binary and not download_link:
454462
return AsyncBinaryAPIResponse(response)
455463

‎src/qca/common/_exceptions.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ class ConflictError(APIStatusError):
6666
pass
6767

6868

69+
class RequestTooLargeError(APIStatusError):
70+
pass
71+
72+
6973
class UnprocessableEntityError(APIStatusError):
7074
pass
7175

@@ -78,6 +82,10 @@ class InternalServerError(APIStatusError):
7882
pass
7983

8084

85+
class OverloadedError(APIStatusError):
86+
pass
87+
88+
8189
def status_error(response: httpx.Response) -> APIStatusError:
8290
try:
8391
body = response.json()
@@ -91,7 +99,9 @@ def status_error(response: httpx.Response) -> APIStatusError:
9199
403: PermissionDeniedError,
92100
404: NotFoundError,
93101
409: ConflictError,
102+
413: RequestTooLargeError,
94103
422: UnprocessableEntityError,
95104
429: RateLimitError,
105+
529: OverloadedError,
96106
}.get(response.status_code, InternalServerError if response.status_code >= 500 else APIStatusError)
97107
return cls(f"Error code: {response.status_code} - {message}", response=response, body=body)

0 commit comments

Comments
 (0)