Skip to content

Commit 97f0a10

Browse files
authored
fix: correct per-operation tracing lifecycle and HTTP transport edge cases (#7)
PR: #7
1 parent 7dcca56 commit 97f0a10

22 files changed

Lines changed: 845 additions & 188 deletions

File tree

CHANGELOG.md

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
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. Most of this
14-
lands in `core`; the transport adapters additionally get consistent connect- vs
15-
read-phase timeout classification and tighter resource release. The only removed
16-
public symbol is the unused `RetryConfig` (see Removed); existing code otherwise
17-
continues to work without modification.
11+
blocks (typed serialization, webhook verification, pagination, three pipeline
12+
policies), tightened retry behaviour, a corrected per-operation tracing
13+
lifecycle, and a batch of correctness fixes across bodies, SSE parsing, Digest
14+
auth, and error reporting. Most of this lands in `core`; the transport adapters
15+
additionally get consistent connect- vs read-phase timeout classification,
16+
tighter resource release, and a set of edge-case corrections (status-code
17+
reporting, chunked-framing detection, and content-length under content-encoding).
18+
The only removed public symbol is the unused `RetryConfig` (see Removed);
19+
existing code otherwise continues to work without modification — with one
20+
behavioural note for hand-assembled pipelines (see *Tracing lifecycle* under
21+
Changed).
1822

1923
### Added
2024

@@ -37,6 +41,16 @@ continues to work without modification.
3741
- **Client-identity policy** (`pipeline.policies.client_identity`, plus its
3842
async twin). Sets a consistent `User-Agent` / client-identity header derived
3943
from the configured application id and SDK version.
44+
- **Per-operation tracing policy** (`OperationTracingPolicy` and its async twin
45+
`AsyncOperationTracingPolicy`, with a new outermost `Stage.OPERATION`). Emits
46+
the per-operation `HttpTracer` lifecycle (`operation_started`, then exactly
47+
one `operation_succeeded` / `operation_failed`) from outside the retry and
48+
redirect wrappers, so the reported outcome reflects the final result of the
49+
whole call rather than a single attempt or hop. Both `default_pipeline` and
50+
`default_async_pipeline` wire it, so the async stack now reports the same
51+
lifecycle alongside the attempt-level events its retry / redirect policies
52+
already emit. Only `TracingPolicy`'s per-attempt OpenTelemetry span policy
53+
remains sync-only.
4054
- **HTTP tracer** (`instrumentation.http_tracer`). An adapter-style tracer base
4155
whose per-event methods default to no-ops, so a subclass overrides only the
4256
events it cares about. Wired through the tracing policy for span emission.
@@ -58,6 +72,17 @@ continues to work without modification.
5872
cancellation cleanly between attempts.
5973
- **Tracing and redirect policies** now emit tracer events and carry correlation
6074
through redirects, with credentials stripped on cross-origin redirects.
75+
- **Tracing lifecycle** (`pipeline.policies.tracing_policy`). The per-operation
76+
`HttpTracer` lifecycle moved out of `TracingPolicy` into the new
77+
`OperationTracingPolicy`; `TracingPolicy` now emits only its per-attempt span
78+
and the per-request events (`request_sent`, `response_headers_received`,
79+
`response_received`). `default_pipeline` wires both, so callers who use it are
80+
unaffected. A pipeline assembled by hand that wants the operation lifecycle
81+
must now add `OperationTracingPolicy` alongside `TracingPolicy` — a bare
82+
`TracingPolicy` no longer emits `operation_started` / `operation_succeeded` /
83+
`operation_failed`. So that change is not silent, a `TracingPolicy` that runs
84+
with a real `HttpTracer` but no `OperationTracingPolicy` bracketing it logs a
85+
one-time warning.
6186
- **Default pipelines** (`pipeline.defaults`). The standard sync/async stacks now
6287
assemble the new idempotency and client-identity policies alongside the
6388
existing retry, redirect, logging, and tracing policies.
@@ -96,6 +121,26 @@ continues to work without modification.
96121
`async_response_body`). Cancelling an in-flight read now releases the
97122
underlying resources instead of leaking them, and re-raises `CancelledError`
98123
after cleanup.
124+
- **Per-operation tracing outcome** (`pipeline.policies.tracing_policy`). A call
125+
retried after a failed first attempt no longer reports `operation_failed` for
126+
the discarded attempt (it reports the single `operation_succeeded` it ends on),
127+
and a redirect whose later hop fails no longer reports `operation_succeeded`
128+
for the earlier 3xx hop. The lifecycle now fires exactly once and reflects the
129+
final outcome. See *Tracing lifecycle* under Changed for the API shape.
130+
- **`Content-Length` under `Content-Encoding`** (`http.stdlib.urllib_http_client`).
131+
`UrllibHttpClient` no longer drops a valid `Content-Length` when
132+
`Content-Encoding` is present: `http.client` does not decode content codings,
133+
so the body it serves is the wire payload whose length the header describes,
134+
and the length is now surfaced as-is. (The decompressing requests/httpx/aiohttp
135+
adapters still drop it, since they hand back a decoded stream.)
136+
- **Chunked-framing detection** (`http.stdlib.asyncio_http_client`). The
137+
`Transfer-Encoding` check matches the `chunked` coding by token rather than
138+
substring, so a coding whose name merely contains `chunked` (e.g. `x-chunked`)
139+
is no longer mistaken for chunked framing.
140+
- **Out-of-range status reporting** (`http.stdlib.urllib_http_client`,
141+
`asyncio_http_client`). Both now raise a `ServiceResponseError` worded
142+
`Invalid status code: …` for a status outside 100–599, matching the other
143+
adapters.
99144

100145
### Verified
101146

@@ -113,6 +158,11 @@ The following were intentionally left out of this round and are **not** included
113158
errors themselves.
114159
- **`sendfile` fast-path** — file bodies are streamed via the existing
115160
`iter_bytes` path; no zero-copy `sendfile` transport optimisation was added.
161+
- **Async OpenTelemetry spans / logging** — the per-attempt span policy
162+
(`TracingPolicy`) and `LoggingPolicy` ship sync-only, so
163+
`default_async_pipeline` emits the per-operation `HttpTracer` lifecycle and
164+
attempt-level events but no OpenTelemetry spans or structured request /
165+
response logs.
116166
- **MCP support** — no Model Context Protocol integration is included.
117167
- **Java SDK items** — the Java counterpart lives in a separate repository and
118168
was out of scope here.

CLAUDE.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ python-sdk/
132132
│ │ │ │
133133
│ │ │ ├── policies/ # redirect, idempotency, retry, set_date,
134134
│ │ │ │ # client_identity, logging, tracing
135-
│ │ │ │ # (async twins only for the first five)
135+
│ │ │ │ # (async twins for all but logging and per-attempt tracing)
136136
│ │ │ └── step/ # PipelineStep, StepMetadata
137137
│ │ ├── client/ # HttpClient + AsyncHttpClient Protocols
138138
│ │ ├── config/ # Configuration
@@ -206,15 +206,17 @@ Layered, bottom-up:
206206
4. **`pipeline`**`Policy` (and `AsyncPolicy`) wrap the downstream chain;
207207
`Pipeline` / `AsyncPipeline` run an ordered set of policies grouped into
208208
`Stage`s via `StagedPipelineBuilder`. Shipped policies: redirect,
209-
idempotency, retry, set-date, client-identity, logging, tracing. Async
210-
twins under `pipeline/policies/` exist only for redirect, idempotency,
211-
retry, set-date, and client-identity; logging and tracing are sync-only.
209+
idempotency, retry, set-date, client-identity, logging, tracing, and
210+
operation-tracing. Async twins exist for redirect, idempotency, retry,
211+
set-date, client-identity, and operation-tracing; logging and the
212+
per-attempt tracing policy are sync-only.
212213
`default_pipeline()` / `default_async_pipeline()` assemble the standard
213-
stack in the order redirect → idempotency → retry → set-date →
214-
client-identity → [auth] → logging → tracing (the async pipeline omits
215-
logging and tracing). The lower-level `pipeline/step/PipelineStep` Protocol
216-
(`(input, context) -> output`) plus `StepMetadata` remain for custom
217-
composition.
214+
stack in the order operation-tracing → redirect → idempotency → retry →
215+
set-date → client-identity → [auth] → logging → tracing (the async
216+
pipeline keeps operation-tracing but omits logging and the per-attempt
217+
tracing span). The lower-level
218+
`pipeline/step/PipelineStep` Protocol (`(input, context) -> output`) plus
219+
`StepMetadata` remain for custom composition.
218220
5. **`client/HttpClient`** — single-method Protocol
219221
(`execute(request) -> Response`). Transport is **not** provided by `core`;
220222
the `dexpace-sdk-http-*` packages (stdlib, httpx, aiohttp, requests) each

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,12 @@ the way back up. The terminal policy hands the request to an `HttpClient`
132132
transport.
133133

134134
```
135-
caller → Pipeline → REDIRECT → POST_REDIRECT → RETRY → POST_RETRY → [AUTH] → LOGGING → POST_LOGGING → HttpClient → wire
136-
(pillar) idempotency (pillar) set-date (pillar) (pillar) tracing
137-
client-identity
135+
caller → Pipeline → OPERATION → REDIRECT → POST_REDIRECT → RETRY → POST_RETRY → [AUTH] → LOGGING → POST_LOGGING → HttpClient → wire
136+
(pillar) (pillar) idempotency (pillar) set-date (pillar) (pillar) tracing
137+
client-identity
138138
```
139139

140-
Ordering is governed by `Stage`, an `IntEnum` whose values sit 100 apart so
140+
Ordering is governed by `Stage`, an `IntEnum` whose values are spaced out so
141141
new stages can land without renumbering. Pillar stages admit a single
142142
policy; non-pillar stages stack with deque semantics. Callers who prefer
143143
explicit ordering can still use the list form,
@@ -176,7 +176,7 @@ Bottom-up, the layers are:
176176
| `http.webhooks` | `WebhookVerifier`, `InvalidWebhookSignatureError` — HMAC signature verification with timestamp tolerance |
177177
| `pagination` | `Page`, `Paginator` / `AsyncPaginator`, `PaginationStrategy` (`CursorStrategy`, `PageNumberStrategy`, `LinkHeaderStrategy`) |
178178
| `pipeline` | `Pipeline`, `AsyncPipeline`, `Policy` ABC, `Stage` enum, `StagedPipelineBuilder`, `default_pipeline()` |
179-
| `pipeline.policies` | `RedirectPolicy`, `IdempotencyPolicy`, `RetryPolicy`, `SetDatePolicy`, `ClientIdentityPolicy`, `LoggingPolicy`, `TracingPolicy` (async twins for all but logging/tracing) |
179+
| `pipeline.policies` | `RedirectPolicy`, `IdempotencyPolicy`, `RetryPolicy`, `SetDatePolicy`, `ClientIdentityPolicy`, `LoggingPolicy`, `OperationTracingPolicy`, `TracingPolicy` (async twins for all but `LoggingPolicy` and `TracingPolicy`) |
180180
| `client` | `HttpClient` and `AsyncHttpClient` Protocols |
181181
| `serde` | `Serde`, `Serializer`, `Deserializer` Protocols + `JsonSerde` reference impl |
182182
| `instrumentation` | `ClientLogger`, `UrlRedactor`, `Tracer`, `Span`, `InstrumentationContext`, `contextvars` correlation helpers, noop singletons |
@@ -205,7 +205,8 @@ Bottom-up, the layers are:
205205
reissue, userinfo dropped from `Location` URLs, configurable allowed
206206
methods and 303 handling.
207207
- **Observability.** Structured logging via `LoggingPolicy`,
208-
OpenTelemetry-compatible spans via `TracingPolicy`, URL redaction with
208+
per-attempt OpenTelemetry spans via `TracingPolicy` with a once-per-call
209+
tracer lifecycle via `OperationTracingPolicy`, URL redaction with
209210
allowlisted query parameters, and capped body capture for diagnostics.
210211
- **Server-Sent Events.** A WHATWG-compliant `SseParser` with a bounded
211212
line buffer, plus reconnecting `SseConnection` / `AsyncSseConnection`

docs/pipelines.md

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,31 +37,44 @@ two parallel variants:
3737
```python
3838
from dexpace.sdk.http.stdlib import UrllibHttpClient
3939
from dexpace.sdk.core.pipeline import Pipeline
40-
from dexpace.sdk.core.pipeline.policies import LoggingPolicy, RetryPolicy, TracingPolicy
40+
from dexpace.sdk.core.pipeline.policies import (
41+
LoggingPolicy,
42+
OperationTracingPolicy,
43+
RetryPolicy,
44+
TracingPolicy,
45+
)
4146

4247
with Pipeline(
4348
UrllibHttpClient(),
44-
policies=[TracingPolicy(), LoggingPolicy(), RetryPolicy()],
49+
policies=[OperationTracingPolicy(), TracingPolicy(), LoggingPolicy(), RetryPolicy()],
4550
) as pipeline:
4651
response = pipeline.run(request, dispatch_ctx)
4752
```
4853

4954
The chain runs in declaration order. For the example above:
5055

51-
1. `TracingPolicy` opens a span.
52-
2. `LoggingPolicy` logs the request.
53-
3. `RetryPolicy` invokes the transport; retries on transient failures.
54-
4. The transport runner calls `UrllibHttpClient.execute(request)`.
55-
5. The unwinding mirror order: logging emits the response, tracing
56-
closes the span.
56+
1. `OperationTracingPolicy` emits `operation_started` for the whole call.
57+
2. `TracingPolicy` opens a span.
58+
3. `LoggingPolicy` logs the request.
59+
4. `RetryPolicy` invokes the transport; retries on transient failures.
60+
5. The transport runner calls `UrllibHttpClient.execute(request)`.
61+
6. The unwinding mirrors that order: logging emits the response, tracing
62+
closes the span, and operation-tracing emits the single
63+
`operation_succeeded` / `operation_failed` once the call settles.
64+
65+
In the canonical `default_pipeline`, `OperationTracingPolicy` sits *outside*
66+
the redirect and retry wrappers (so the per-operation lifecycle fires once and
67+
reflects the final outcome), while `TracingPolicy` sits *inside* them and opens
68+
one span per attempt / hop.
5769

5870
## Built-in policies
5971

6072
| Policy | Purpose |
6173
|-------------------------------------|----------------------------------------------------------|
6274
| `RetryPolicy` / `AsyncRetryPolicy` | Retry transient failures with backoff + `Retry-After`. Auto-buffers single-use request bodies when `total_retries > 0`. |
6375
| `LoggingPolicy` | Structured request/response logs with URL redaction. |
64-
| `TracingPolicy` | Open a span per request; OTel semantic-conv attributes. |
76+
| `OperationTracingPolicy` | Emit the per-operation lifecycle (`operation_started`, then one `operation_succeeded`/`operation_failed`) from outside the retry/redirect loop. |
77+
| `TracingPolicy` | Open a span per attempt and emit per-request tracer events; OTel semantic-conv attributes. |
6578
| `BearerTokenPolicy` (auth) | Acquire + cache + apply OAuth bearer tokens. |
6679
| `KeyCredentialPolicy` (auth) | Stamp an API key into a configurable header. |
6780
| `BasicAuthPolicy` (auth) | `Authorization: Basic <base64>`. |
@@ -88,16 +101,19 @@ Per-call opt-outs follow a convention:
88101
second `iter_bytes()` call raises `RuntimeError`. To keep retries safe
89102
without forcing every caller to remember `to_replayable()`, both
90103
`RetryPolicy.send` and `AsyncRetryPolicy.send` inspect the body up front
91-
and, when `total_retries > 0`, swap in a buffered replayable copy before
92-
the first attempt:
104+
and, when the *effective* retry total for the call is positive, swap in a
105+
buffered replayable copy before the first attempt:
93106

94107
```python
95-
if total_retries > 0 and request.body is not None and not request.body.is_replayable():
108+
settings = self._configure_settings(ctx.options)
109+
if settings["total"] > 0 and request.body is not None and not request.body.is_replayable():
96110
request = request.with_body(request.body.to_replayable())
97111
```
98112

99-
`total_retries == 0` (e.g. `RetryPolicy.no_retries()`) skips the buffering
100-
step so callers who explicitly opt out of retries pay no memory cost for a
101-
copy they will never use. Already-replayable bodies (`from_bytes`,
102-
`from_string`, `from_form`) flow through untouched because
103-
`to_replayable()` returns `self`.
113+
The decision reads the effective total *after* merging any per-call
114+
`retry_total` override (from `ctx.options`) with the constructor default, so a
115+
per-call `retry_total=3` over an instance built with `total_retries=0` still
116+
buffers, and a per-call `retry_total=0` (or `RetryPolicy.no_retries()`) skips
117+
the buffering so callers who opt out of retries pay no memory cost for a copy
118+
they will never use. Already-replayable bodies (`from_bytes`, `from_string`,
119+
`from_form`) flow through untouched because `to_replayable()` returns `self`.

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

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ...pipeline.policy import Policy
1515
from ...pipeline.stage import Stage
1616
from ...util.clock import ASYNC_SYSTEM_CLOCK, SYSTEM_CLOCK, AsyncClock, Clock
17+
from ..common.url import _origin
1718
from .access_token import AccessTokenInfo, TokenRequestOptions
1819
from .challenge import parse_challenges
1920
from .challenge_handler import ChallengeHandler
@@ -439,29 +440,9 @@ async def _authorize(
439440
return request.with_header("Authorization", f"{token.token_type} {token.token}")
440441

441442

442-
_DEFAULT_PORTS: dict[str, int] = {"https": 443, "http": 80}
443443
_AUTH_ORIGIN_KEY: str = "_auth_origin"
444444

445445

446-
def _origin(url: Url) -> tuple[str, str, int | None]:
447-
"""Return the ``(scheme, host, port)`` origin tuple for ``url``.
448-
449-
The scheme and host are lower-cased and the port is resolved to its
450-
scheme default (443 for https, 80 for http) when not explicit, so two
451-
URLs that differ only in an implied/explicit default port compare equal.
452-
453-
Args:
454-
url: The URL to derive an origin from.
455-
456-
Returns:
457-
A ``(scheme, host, effective_port)`` tuple suitable for equality
458-
comparison.
459-
"""
460-
scheme = url.scheme.lower()
461-
port = url.port if url.port is not None else _DEFAULT_PORTS.get(scheme)
462-
return scheme, url.host.lower(), port
463-
464-
465446
def _crosses_recorded_origin(request: Request, ctx: PipelineContext) -> bool:
466447
"""Report whether ``request`` left the origin recorded for this operation.
467448

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,4 +320,28 @@ def parse(cls, raw: str) -> Self:
320320
)
321321

322322

323+
_DEFAULT_PORTS: dict[str, int] = {"https": 443, "http": 80}
324+
325+
326+
def _origin(url: Url) -> tuple[str, str, int | None]:
327+
"""Return the ``(scheme, host, effective_port)`` origin tuple for ``url``.
328+
329+
The scheme and host are lower-cased and the port is resolved to its scheme
330+
default (443 for https, 80 for http) when not explicit, so two URLs that
331+
differ only in an implied/explicit default port compare equal. Shared by
332+
the redirect and auth policies for their cross-origin checks so the origin
333+
definition stays in one place.
334+
335+
Args:
336+
url: The URL to derive an origin from.
337+
338+
Returns:
339+
A ``(scheme, host, effective_port)`` tuple suitable for equality
340+
comparison.
341+
"""
342+
scheme = url.scheme.lower()
343+
port = url.port if url.port is not None else _DEFAULT_PORTS.get(scheme)
344+
return scheme, url.host.lower(), port
345+
346+
323347
__all__ = ["QueryParams", "Url"]

0 commit comments

Comments
 (0)