Skip to content

Commit 481c09c

Browse files
authored
feat(core): typed serde, pagination, webhooks, tracing, and fixes (#3)
PR: #3
1 parent 99cfe28 commit 481c09c

69 files changed

Lines changed: 10268 additions & 123 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
A round of platform improvements to `dexpace-sdk-core`: new optional building
11+
blocks (typed serialization, webhook verification, pagination, two pipeline
12+
policies), tightened retry and tracing behaviour, and a batch of correctness
13+
fixes across bodies, SSE parsing, Digest auth, and error reporting. Everything
14+
lands in `core`; the transport packages are unchanged. No public symbol was
15+
removed, so existing code continues to work without modification.
16+
17+
### Added
18+
19+
- **Tristate values** (`serde.tristate`). A three-way type distinguishing
20+
"set to a value", "explicitly set to null", and "absent", so partial updates
21+
(PATCH-style payloads) can round-trip an explicit `null` without conflating it
22+
with an omitted field.
23+
- **Typed model codec** (`serde.codec`). A small encode/decode layer over the
24+
existing `Serde` protocol for converting between typed models and wire bytes,
25+
built on the standard library only. This is the largest new surface and is
26+
worth a careful read before depending on it.
27+
- **Webhook signature verification** (`http.webhooks`). Helpers to verify the
28+
authenticity of inbound webhook payloads using constant-time comparison.
29+
- **Pagination** (`pagination`). A paginator abstraction with pluggable
30+
next-page strategies, a `Link` header parser, and a page model, so list
31+
endpoints can be iterated without each caller re-implementing cursor handling.
32+
- **Idempotency-key policy** (`pipeline.policies.idempotency`, plus its async
33+
twin). Stamps a generated idempotency key onto retriable, non-idempotent
34+
requests so safe automatic retries don't double-apply a side effect.
35+
- **Client-identity policy** (`pipeline.policies.client_identity`, plus its
36+
async twin). Sets a consistent `User-Agent` / client-identity header derived
37+
from the configured application id and SDK version.
38+
- **HTTP tracer** (`instrumentation.http_tracer`). An adapter-style tracer base
39+
whose per-event methods default to no-ops, so a subclass overrides only the
40+
events it cares about. Wired through the tracing policy for span emission.
41+
- **Log correlation** (`instrumentation.correlation`). A `contextvar`-backed
42+
correlation id that flows through the pipeline and is attached to log records,
43+
so logs from a single logical request can be tied together.
44+
45+
### Changed
46+
47+
- **Retry tuning** (`pipeline.policies.retry` / `async_retry`). More
48+
configurable backoff and clearer rules for which responses and exceptions are
49+
retried, including respecting `Retry-After`. The async retry path now observes
50+
cancellation cleanly between attempts.
51+
- **Tracing and redirect policies** now emit tracer events and carry correlation
52+
through redirects, with credentials stripped on cross-origin redirects.
53+
- **Default pipelines** (`pipeline.defaults`). The standard sync/async stacks now
54+
assemble the new idempotency and client-identity policies alongside the
55+
existing retry, redirect, logging, and tracing policies.
56+
- **Loggable bodies** (`http.request.loggable_request_body`,
57+
`http.response.loggable_response_body`). Capture is bounded and repeatable
58+
reads behave correctly; the byte cap is honoured on the tap without truncating
59+
the primary write path.
60+
- **Error reporting** (`errors.http`). HTTP errors now expose whether they are
61+
`retryable` and carry a bounded body snapshot for diagnostics, with the
62+
snapshot capped so an error never holds an unbounded payload.
63+
64+
### Fixed
65+
66+
- **SSE parsing** (`http.sse.parser`) now strips a leading UTF-8 byte-order mark
67+
and cleans up the async stream deterministically on cancellation or exit.
68+
- **Digest auth** (`http.auth.digest`) honours the server-advertised charset
69+
when computing the digest, fixing authentication against servers that send
70+
non-ASCII credentials.
71+
- **MediaType** (`http.common.media_type`) handles parameter parsing edge cases
72+
(quoting, casing, and whitespace) more robustly.
73+
- **Async response cancellation** (`http.response.async_response`,
74+
`async_response_body`). Cancelling an in-flight read now releases the
75+
underlying resources instead of leaking them, and re-raises `CancelledError`
76+
after cleanup.
77+
78+
### Verified
79+
80+
- `mypy --strict`, `ruff check`, `ruff format --check`, and `pytest` run in CI
81+
across the supported Python matrix (3.12–3.14). New modules ship with tests
82+
under each package's `tests/` tree, and `py.typed` continues to ship so
83+
downstream type-checkers consume the annotations.
84+
85+
### Honest scope boundaries
86+
87+
The following were intentionally left out of this round and are **not** included:
88+
89+
- **Default error map** — error classification beyond the `retryable`
90+
flag and body snapshot was deferred; callers still map status codes to domain
91+
errors themselves.
92+
- **`sendfile` fast-path** — file bodies are streamed via the existing
93+
`iter_bytes` path; no zero-copy `sendfile` transport optimisation was added.
94+
- **MCP support** — no Model Context Protocol integration is included.
95+
- **Java SDK items** — the Java counterpart lives in a separate repository and
96+
was out of scope here.
97+
- **Code generation** — no client/model code generation was added; all surfaces
98+
in this release are hand-written.
99+
100+
[Unreleased]: https://github.com/dexpace/python-sdk/compare/main...HEAD

packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/http.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55

66
from __future__ import annotations
77

8-
from typing import TYPE_CHECKING, Any, Generic, TypeVar
8+
from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar
99

10+
from ..http.response.loggable_response_body import LoggableResponseBody
1011
from .base import SdkError
1112

1213
if TYPE_CHECKING:
@@ -26,6 +27,12 @@
2627
else:
2728
ModelT = TypeVar("ModelT")
2829

30+
# Status codes for which a retry is worthwhile by default: request timeout,
31+
# rate limiting, and the transient 5xx family. Mirrors the retry policy's
32+
# ``_DEFAULT_STATUS_RETRIES`` so ``retryable`` and the policy agree out of
33+
# the box; callers can override per error via the ``retryable`` kwarg.
34+
_DEFAULT_RETRYABLE_STATUS: Final[frozenset[int]] = frozenset({408, 429, 500, 502, 503, 504})
35+
2936

3037
# UP046 wants PEP 695 ``class Foo[T = Any](...)`` form, but that syntax
3138
# requires Python 3.13+ at runtime; we still support 3.12.
@@ -49,12 +56,18 @@ class HttpResponseError(SdkError, Generic[ModelT]): # noqa: UP046
4956
model: Optional deserialised body payload (set by consumer
5057
libraries when they parse the error body). Typed as
5158
``ModelT | None``.
59+
retryable: Whether retrying the request might succeed. Derived from
60+
the response status by default (request timeout, rate limiting,
61+
and transient 5xx are retryable) so the retry policy can read the
62+
flag directly instead of re-deriving it; callers may override it
63+
explicitly via the ``retryable`` constructor keyword.
5264
"""
5365

5466
status: Status | None
5567
reason: str | None
5668
response: _AnyResponse | None
5769
model: ModelT | None
70+
retryable: bool
5871

5972
def __init__(
6073
self,
@@ -70,17 +83,60 @@ def __init__(
7083
response: The HTTP response that triggered the error.
7184
**kwargs: Forwarded to ``SdkError`` (``error``,
7285
``continuation_token``). The ``model`` key is consumed
73-
separately for caller-supplied deserialised bodies.
86+
separately for caller-supplied deserialised bodies. The
87+
``retryable`` key, if given, overrides the status-derived
88+
default (pass ``True``/``False`` to force it).
7489
"""
7590
self.response = response
7691
self.status = response.status if response is not None else None
7792
self.reason = response.reason if response is not None else None
7893
self.model = kwargs.pop("model", None)
94+
retryable_override = kwargs.pop("retryable", None)
95+
self.retryable = (
96+
self._status_is_retryable() if retryable_override is None else bool(retryable_override)
97+
)
7998
if message is None:
8099
label = self.status.name if self.status is not None else "unknown"
81100
message = f"Operation returned a non-success status: {label}"
82101
super().__init__(message, **kwargs)
83102

103+
def _status_is_retryable(self) -> bool:
104+
"""Return whether this error's status is retryable by default.
105+
106+
Returns:
107+
``True`` when the captured status is one of the default
108+
retryable codes, ``False`` when no status was captured.
109+
"""
110+
return self.status is not None and int(self.status) in _DEFAULT_RETRYABLE_STATUS
111+
112+
def body_snapshot(self, max_bytes: int | None = None) -> bytes:
113+
"""Preview the error response body without consuming it.
114+
115+
Safe to call from logging and post-mortem paths: it never drains a
116+
single-use stream. Bytes are only returned when the body has already
117+
been captured for repeatable reads (a ``LoggableResponseBody``); for
118+
any other body — or when no response/body is present — an empty
119+
``bytes`` is returned rather than destroying the payload.
120+
121+
Args:
122+
max_bytes: If given, return at most this many bytes from the
123+
front of the captured body. ``None`` returns the full
124+
capture.
125+
126+
Returns:
127+
The captured body bytes, optionally truncated to ``max_bytes``;
128+
empty when no non-consuming preview is available.
129+
130+
Raises:
131+
ValueError: If ``max_bytes`` is negative.
132+
"""
133+
if max_bytes is not None and max_bytes < 0:
134+
raise ValueError(f"max_bytes must be non-negative, got {max_bytes}")
135+
body = self.response.body if self.response is not None else None
136+
if isinstance(body, LoggableResponseBody):
137+
return body.snapshot(max_bytes)
138+
return b""
139+
84140

85141
class DecodeError(HttpResponseError[ModelT]):
86142
"""The response body could not be decoded as the expected format."""

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/digest.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def handle(
107107
realm = selected.parameters.get("realm", "")
108108
nonce = selected.parameters.get("nonce", "")
109109
opaque = selected.parameters.get("opaque")
110+
charset = _select_charset(selected.parameters.get("charset"))
110111
qop = self._pick_qop(selected.parameters.get("qop"))
111112
if qop is None and "qop" in selected.parameters:
112113
# The server advertised qop but did not include ``auth``: we
@@ -125,6 +126,7 @@ def handle(
125126
nc=nc,
126127
cnonce=cnonce,
127128
qop=qop,
129+
charset=charset,
128130
)
129131
header_value = _format_header(
130132
username=self._username,
@@ -188,9 +190,10 @@ def _compute_response(
188190
nc: str,
189191
cnonce: str,
190192
qop: str | None,
193+
charset: str,
191194
) -> str:
192195
def h(data: str) -> str:
193-
return hasher(data.encode("utf-8")).hexdigest()
196+
return hasher(data.encode(charset)).hexdigest()
194197

195198
ha1 = h(f"{self._username}:{realm}:{self._password}")
196199
if algorithm.endswith("-SESS"):
@@ -202,6 +205,28 @@ def h(data: str) -> str:
202205
return h(f"{ha1}:{nonce}:{ha2}")
203206

204207

208+
def _select_charset(charset_param: str | None) -> str:
209+
"""Choose the encoding for credential hashing per RFC 7616 §3.4.
210+
211+
RFC 7616 defines exactly one valid ``charset`` value — ``UTF-8`` — which
212+
a server advertises to request that ``username`` and ``password`` be
213+
encoded as UTF-8 before hashing. When the directive is absent (or carries
214+
any other value), the legacy RFC 2617 default of ISO-8859-1 applies.
215+
216+
Args:
217+
charset_param: The raw ``charset`` directive from the challenge, or
218+
``None`` if the server did not send one. Matched case-insensitively
219+
against ``UTF-8``.
220+
221+
Returns:
222+
The Python codec name to pass to ``str.encode`` — ``"utf-8"`` when the
223+
server advertised ``charset=UTF-8``, otherwise ``"iso-8859-1"``.
224+
"""
225+
if charset_param is not None and charset_param.strip().upper() == "UTF-8":
226+
return "utf-8"
227+
return "iso-8859-1"
228+
229+
205230
def _request_uri(url: Url) -> str:
206231
"""Compute the ``uri`` parameter — path plus query, per RFC 7616 §3.4.6."""
207232
path = url.path or "/"

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/media_type.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import codecs
89
from collections.abc import Mapping
910
from dataclasses import dataclass
1011
from typing import Self
@@ -75,9 +76,19 @@ def full_type(self) -> str:
7576

7677
@property
7778
def charset(self) -> str | None:
78-
"""The ``charset`` parameter, or ``None`` if absent."""
79+
"""The ``charset`` parameter as a known codec name, or ``None``.
80+
81+
Returns ``None`` when the parameter is absent *or* names an encoding
82+
that the Python codec registry does not recognise. Degrading an
83+
unknown charset to ``None`` (rather than raising) lets callers fall
84+
back to a default encoding instead of failing to decode a body.
85+
"""
7986
for key, value in self.parameters:
8087
if key == "charset":
88+
try:
89+
codecs.lookup(value)
90+
except (LookupError, ValueError):
91+
return None
8192
return value
8293
return None
8394

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/streaming.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator
1414
from typing import Any
1515

16-
from ...errors import DeserializationError
16+
from ...errors.serialization import DeserializationError
1717

1818

1919
def iter_jsonl(chunks: Iterable[bytes]) -> Iterator[Any]:

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/loggable_request_body.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,26 @@ def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
7676
self._tap.write(chunk[:remaining])
7777
yield chunk
7878

79-
def snapshot(self) -> bytes:
80-
"""Return an immutable copy of the captured bytes."""
81-
return self._tap.getvalue()
79+
def snapshot(self, max_bytes: int | None = None) -> bytes:
80+
"""Return an immutable copy of the captured bytes.
81+
82+
Args:
83+
max_bytes: If given, copy at most this many bytes from the front
84+
of the tap. A ``memoryview`` bounds the slice so no more than
85+
``max_bytes`` are ever materialised, even when the tap holds a
86+
large payload. ``None`` returns the full tap.
87+
88+
Returns:
89+
The captured bytes, optionally truncated to ``max_bytes``.
90+
91+
Raises:
92+
ValueError: If ``max_bytes`` is negative.
93+
"""
94+
if max_bytes is None:
95+
return self._tap.getvalue()
96+
if max_bytes < 0:
97+
raise ValueError(f"max_bytes must be non-negative, got {max_bytes}")
98+
return bytes(self._tap.getbuffer()[:max_bytes])
8299

83100
@property
84101
def captured_size(self) -> int:

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_response.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ..common.headers import Headers
1313
from ..common.http_header_name import HttpHeaderName
1414
from ..common.protocol import Protocol
15+
from .async_response_body import _shielded_cleanup
1516
from .status import Status
1617

1718
if TYPE_CHECKING:
@@ -38,9 +39,14 @@ class AsyncResponse:
3839
body: AsyncResponseBody | None = None
3940

4041
async def close(self) -> None:
41-
"""Close the response body. Idempotent."""
42+
"""Close the response body. Idempotent.
43+
44+
When invoked from ``__aexit__`` while an ``asyncio.CancelledError`` is
45+
propagating out of an ``async with`` block, the body close is shielded
46+
so the transport handle is released before cancellation continues.
47+
"""
4248
if self.body is not None:
43-
await self.body.close()
49+
await _shielded_cleanup(self.body.close())
4450

4551
async def __aenter__(self) -> Self:
4652
return self

0 commit comments

Comments
 (0)