021-python-clients 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/CHANGELOG.md +20 -0
  2. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/CONTRACT.md +91 -13
  3. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/CONTRIBUTING.md +11 -6
  4. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/PKG-INFO +58 -15
  5. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/README.md +57 -14
  6. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/pyproject.toml +1 -1
  7. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/__init__.py +1 -1
  8. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/email/resend.py +5 -1
  9. 021_python_clients-0.3.0/src/clients_021/http/__init__.py +11 -0
  10. 021_python_clients-0.3.0/src/clients_021/http/_observe.py +175 -0
  11. 021_python_clients-0.3.0/src/clients_021/http/httpx.py +280 -0
  12. 021_python_clients-0.3.0/src/clients_021/http/httpx2.py +76 -0
  13. 021_python_clients-0.3.0/src/clients_021/httpx2.py +5 -0
  14. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/queue/qstash.py +1 -1
  15. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_contract.py +1 -1
  16. 021_python_clients-0.3.0/tests/test_http_stream_lifecycle.py +270 -0
  17. 021_python_clients-0.3.0/tests/test_http_streaming.py +482 -0
  18. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_httpx2.py +8 -2
  19. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/wheel_minimal.py +11 -1
  20. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/wheel_smoke.py +12 -3
  21. 021_python_clients-0.2.0/src/clients_021/http.py +0 -131
  22. 021_python_clients-0.2.0/src/clients_021/httpx2.py +0 -93
  23. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/.gitignore +0 -0
  24. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/LICENSE +0 -0
  25. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/_aws.py +0 -0
  26. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/_observe.py +0 -0
  27. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/cache/__init__.py +0 -0
  28. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/cache/redis.py +0 -0
  29. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/crypto/__init__.py +0 -0
  30. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/crypto/kms.py +0 -0
  31. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/email/__init__.py +0 -0
  32. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/email/_message.py +0 -0
  33. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/email/ses.py +0 -0
  34. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/errors.py +0 -0
  35. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/kms.py +0 -0
  36. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/py.typed +0 -0
  37. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/queue/__init__.py +0 -0
  38. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/queue/sqs.py +0 -0
  39. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/redis.py +0 -0
  40. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/s3.py +0 -0
  41. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/storage/__init__.py +0 -0
  42. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/storage/s3.py +0 -0
  43. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/src/clients_021/storage/vercel_blob.py +0 -0
  44. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/__init__.py +0 -0
  45. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/conftest.py +0 -0
  46. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/qstash_local.py +0 -0
  47. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_email.py +0 -0
  48. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_http.py +0 -0
  49. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_kms.py +0 -0
  50. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_qstash.py +0 -0
  51. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_redis.py +0 -0
  52. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_s3.py +0 -0
  53. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_ses.py +0 -0
  54. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_sqs.py +0 -0
  55. {021_python_clients-0.2.0 → 021_python_clients-0.3.0}/tests/test_vercel_blob.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes are recorded here by release-please from Conventional Commits.
4
4
 
5
+ ## [0.3.0](https://github.com/from021-io/python-clients/compare/v0.2.0...v0.3.0) (2026-10-21)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * **http:** HTTP durations include body consumption and cleanup, counts emit at completion and all HTTP series gain http.response.body.complete. Attempt failure logs are enabled by default. See CONTRACT.md for dashboard and consumer migration notes.
11
+
12
+ ### Features
13
+
14
+ * **http:** observe complete response bodies and streams ([333cece](https://github.com/from021-io/python-clients/commit/333cece69c41a9ef16828f83e303be4183a5a187))
15
+
16
+
17
+ ### Refactors
18
+
19
+ * **http:** group clients and transports by capability ([1a7b41a](https://github.com/from021-io/python-clients/commit/1a7b41a5dba214fb109acccc9e3e08a9d123e3ee))
20
+
5
21
  ## [0.2.0](https://github.com/from021-io/python-clients/compare/v0.1.0...v0.2.0) (2026-10-21)
6
22
 
7
23
 
@@ -22,10 +38,14 @@ All notable changes are recorded here by release-please from Conventional Commit
22
38
 
23
39
  ### Added
24
40
 
41
+ - Add `HttpClient.stream` and a native httpx `ObservedTransport` for incremental bodies with explicit lifetime and no streaming retries.
42
+
25
43
  - Add an optional Vercel Blob client with observed CRUD, explicit public/private access, bounded operations and SDK-native metadata/errors.
26
44
 
27
45
  ### Changed
28
46
 
47
+ - **Breaking — HTTP telemetry:** HTTP/httpx2 spans and request durations now include body consumption and resource close. Counters are emitted at completion, gain `http.response.body.complete`, and failures produce sanitized attempt logs. QStash/Resend retain one logical failure log. Cancellation closes resources before propagating; closed HTTP clients cannot reopen.
48
+
29
49
  - Group Redis, S3 and KMS under cache, storage and crypto, retaining the 0.1.0 imports as compatibility aliases.
30
50
 
31
51
  ## 0.1.0 (2026-10-21)
@@ -19,7 +19,11 @@ Redis lives in `clients_021.cache.redis`, S3 in `clients_021.storage.s3` and KMS
19
19
  `clients_021.crypto.kms`. The 0.1.0 root modules (`redis`, `s3`, `kms`) continue to re-export
20
20
  the same clients and result types (`ObjectInfo`, `DataKey`). Existing call signatures,
21
21
  metric/span names and error semantics are unchanged. Capability package imports do not
22
- load optional SDKs. HTTP transports remain in `clients_021.http` and `clients_021.httpx2`.
22
+ load optional SDKs. HTTP implementations live in `clients_021.http.httpx` and
23
+ `clients_021.http.httpx2`. `clients_021.http` retains `HttpClient`, `HttpResponse`,
24
+ `HttpMethod`, `IDEMPOTENT_METHODS` and `RETRY_STATUS`; the root `clients_021` exports remain
25
+ unchanged. `clients_021.httpx2.ObservedTransport` re-exports the same class as the new path.
26
+ Importing `clients_021.http` does not require the `httpx2` extra.
23
27
 
24
28
  ## Metrics
25
29
 
@@ -36,7 +40,7 @@ version, environment). Durations use 021-python-observability's buckets.
36
40
  | `storage.client.operation.duration` | Histogram / `s` | same | S3, Vercel Blob |
37
41
  | `crypto.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | KMS |
38
42
  | `crypto.client.operation.duration` | Histogram / `s` | same | KMS |
39
- | `http.client.request.count` | Counter / `{request}` | dependency, http.request.method, http.response.status_code, error.type | HTTP, httpx2, QStash transport, Resend (its HTTP request) |
43
+ | `http.client.request.count` | Counter / `{request}` | dependency, http.request.method, http.response.status_code, error.type, http.response.body.complete | HTTP, httpx2, QStash transport, Resend (its HTTP request) |
40
44
  | `http.client.request.duration` | Histogram / `s` | same | HTTP, httpx2, QStash transport, Resend (its HTTP request) |
41
45
 
42
46
  - `status`: `ok` or `error`. `error.type` only on errors.
@@ -52,7 +56,7 @@ version, environment). Durations use 021-python-observability's buckets.
52
56
  `NotFoundException`, for KMS).
53
57
  - Resend records two levels: the `send` operation (`messaging.client.operation.*`) and,
54
58
  inside it, its HTTP request (`http.client.request.*`), both under the same `dependency`.
55
- - HTTP transport metrics inherit 021-python-observability's labels (`timeout`, `cancelled`,
59
+ - HTTP transport metrics use the bounded labels (`timeout`, `cancelled`,
56
60
  `connection_error` or `internal_error`). These describe the transport exception, while
57
61
  `DependencyUnavailable.reason` describes the failure returned to the caller.
58
62
  - httpx2 uses `timeout`, `connection_error`, `cancelled`, or the exception class name.
@@ -70,21 +74,69 @@ metric labels plus `db.system.name=redis`, `messaging.system=aws_sqs`,
70
74
  Resend's `HTTP <dependency>` span is a child of its `<dependency> send` span. Status ERROR on failure (and on HTTP
71
75
  status ≥ 400).
72
76
  Cancellation propagates unchanged and records ERROR with `error.type=cancelled`.
73
- HTTP transports measure through response headers; they do not measure body consumption.
77
+ HTTP transports measure from sending the request through response-body EOF and resource
78
+ close, or explicit early close. Uploads and incremental downloads are included, as are
79
+ consumer pauses between chunks and cleanup latency. Each attempt ends its span and emits
80
+ one count and one duration exactly once. An HTTP 200 followed by a body timeout is ERROR
81
+ with status code 200 and `error.type=timeout`; the status code describes the headers only.
82
+
83
+ `http.response.body.complete` is a boolean on HTTP spans and metric points: true after
84
+ response-body EOF, false when no response arrived or it was closed before EOF. It does not
85
+ imply success: an HTTP rejection or close failure can still set ERROR. An intentional
86
+ partial close without an error does not set ERROR. Cancellation sets `error.type=cancelled`,
87
+ including cancellation while reading, closing or waiting between chunks in a stream context.
88
+
89
+ The HTTP span is current only while sending, reading, closing and emitting its log. It is
90
+ not left attached while a caller processes a chunk; interleaved and cross-task consumers
91
+ keep their own context. All HTTP attempt spans retain the parent active at request start.
74
92
 
75
93
  QStash adds one logical `publish` operation around the HTTP transport request. The
76
94
  messaging duration includes reading and validating the acknowledgement; the nested HTTP
77
- span measures through response headers. Both use the configured static dependency name.
95
+ span includes reading and closing the response body. Both use the configured static dependency name.
78
96
  Local input validation occurs before either operation is recorded.
79
97
 
98
+ ## Logging and coverage limits
99
+
100
+ Redis, SQS, QStash, S3, Vercel Blob, Resend, SES and KMS emit a structured
101
+ `dependency.call_failed` warning when an observed remote operation raises an exception.
102
+ Fields are `dependency`, `operation` and `error_type`; provider messages and exception
103
+ chains are not logged. Successful calls and cancellation do not emit this warning;
104
+ cancellation still records an error span and metrics.
105
+
106
+ HTTP and httpx2 emit one `dependency.call_failed` warning per failed attempt (transport,
107
+ body or close exception, cancellation, or HTTP status >= 400). Fields are only `dependency`,
108
+ `operation` (normalized method) and `error_type`. HTTP rejection logs use `status_<code>`;
109
+ the metric still uses only its status-code label for that case. The logger runs within the
110
+ HTTP span so configured log processors can attach its trace/span IDs. Successful completed
111
+ attempts emit no log. Intentional partial close emits `dependency.stream_closed` at INFO
112
+ with dependency and operation; it is not an outage.
113
+
114
+ `log_failures=False` suppresses these HTTP attempt logs without disabling metrics or spans.
115
+ QStash and Resend set this option because their logical operation already emits a failure
116
+ warning; they do not log the same failure at both levels. SDK retry loops can produce one
117
+ warning per failed attempt. HTTP counters measure attempts, not logical API/SDK calls.
118
+
119
+ The transport observes network bytes and connection cleanup. Parsing JSON, SSE events,
120
+ model output, content decoding above the raw stream, and application errors while processing
121
+ chunks belong to the caller/SDK operation. A 200 SSE response containing an application
122
+ error event is not detected by an HTTP transport.
123
+ S3 presigning and local AES work in KMS are outside remote-operation telemetry.
124
+
125
+ Instrumentation takes effect only when applications use these wrappers. The application
126
+ must also configure logging, exporters and shutdown/flush through 021-python-observability.
127
+ Defining a client in this package does not migrate existing SDK usage or guarantee that
128
+ telemetry reaches an external backend.
129
+
80
130
  ## Trace propagation
81
131
 
82
- - HTTP (`HttpClient`): `traceparent`, `tracestate` and `x-request-id` are sent (by
83
- 021-python-observability's transport).
84
- - httpx2 (`ObservedTransport`): nothing is sent unless `propagate=True`.
132
+ - HTTP (`HttpClient`, `http.httpx.ObservedTransport`): the current `traceparent` and
133
+ `tracestate` replace the caller's trace pair, and a bound `x-request-id` is sent.
134
+ A native httpx transport can disable injection with `propagate=False`.
135
+ - httpx2 (`ObservedTransport`): no context is injected unless `propagate=True`.
136
+ With injection disabled, caller-supplied headers are left as supplied.
85
137
  When enabled, both caller-supplied trace headers are replaced with the current context.
86
138
  - Resend: nothing is sent. The `traceparent`, `tracestate` and `x-request-id` headers that
87
- 021-python-observability's transport adds are removed before the request leaves.
139
+ the HTTP transport adds are removed before the request leaves.
88
140
  - SES, KMS and Vercel Blob: nothing is sent by the default transports.
89
141
  - SQS: `traceparent` and `tracestate` are message attributes; `ReceivedMessage.trace_context()`
90
142
  returns the producer's context. Caller-supplied values for both trace attributes are
@@ -98,10 +150,12 @@ Local input validation occurs before either operation is recorded.
98
150
 
99
151
  `DependencyUnavailable(dependency, operation, reason)` with `reason` one of `timeout`,
100
152
  `connection_error`, `throttled`, `status_<5xx>`. Raised only when retrying later may work.
101
- HTTP: only after the last attempt; HTTP statuses are returned, never raised.
153
+ `HttpClient.request`: only after the last attempt; HTTP statuses are returned, never raised.
154
+ `HttpClient.stream`: exactly one attempt; transport/body failures use the same conversion,
155
+ with no replay after headers or a partial body. Native httpx/httpx2 `ObservedTransport`
156
+ instances preserve SDK exceptions and never retry.
102
157
  HTTP local protocol/unsupported protocol errors and Redis authentication
103
- or authorization errors retain their native type and are not treated as outages. httpx2
104
- transports always preserve the native exception for the owning SDK.
158
+ or authorization errors retain their native type and are not treated as outages.
105
159
 
106
160
  QStash maps HTTP 429 to `throttled`, 5xx to `status_<code>`, and transport outages to
107
161
  `timeout` or `connection_error`, with operation `publish`. Other non-2xx responses raise
@@ -169,9 +223,22 @@ provider options. The publication timeout is mandatory and finite. Consumers rem
169
223
  responsible for idempotency and signature verification. A closed QStash client cannot
170
224
  publish again.
171
225
 
172
- `HttpClient.request` accepts at most one of `json`, `form` or raw byte `content`. Passing
226
+ `HttpClient.request` and `HttpClient.stream` accept at most one of `json`, `form` or raw byte `content`. Passing
173
227
  more than one non-None body format raises `ValueError` before sending a request.
174
228
 
229
+ `HttpClient.stream` is an async context manager yielding a native httpx response; it does
230
+ not buffer the response and also accepts an async iterable of upload bytes. Use `async with`
231
+ and `aiter_bytes`, `aiter_lines` or `aiter_raw`. SDK callers using manual `send(stream=True)`
232
+ must eventually call `response.aclose()`; an abandoned response cannot finalize its
233
+ telemetry or free its connection. Client shutdown does not replace closing active responses.
234
+ Close responses and finish consumers before closing the client/Observability.
235
+
236
+ Body resources close once on EOF, read failure or explicit close. Cleanup is shielded from
237
+ cancellation and awaited; cancellation is then re-raised. A secondary cleanup failure does
238
+ not replace a read failure. These async clients run on asyncio. Custom transports must implement a terminating `aclose()`.
239
+ Timeouts remain per network I/O, not a total stream deadline. `HttpClient` closes an injected
240
+ transport once even when multiple dependency pools borrow it; closed clients cannot reopen.
241
+
175
242
  Vercel Blob uses the optional `vercel-blob>=0.5,<0.6` SDK and explicit read-write credentials,
176
243
  store ID and access mode. It owns a shared transport; each operation borrows it in a separate
177
244
  SDK session. No environment credentials or global SDK client perform requests. The total
@@ -181,6 +248,17 @@ retries operations; an injected transport must respect this policy. Overwrite an
181
248
  suffixes default to false. Callers decide whether a repeated write is safe. A closed client
182
249
  cannot reopen its pool. No additional HTTP metrics are emitted by this client.
183
250
 
251
+ ## Migration from 0.2.x
252
+
253
+ This is a breaking telemetry change, released with the next pre-1.0 minor version. HTTP
254
+ metric names and span names stay the same, but their durations now include bodies, consumer
255
+ backpressure and close; counts are emitted at completion instead of at headers. Existing
256
+ series gain `http.response.body.complete`. Revisit latency thresholds, filter/aggregate the
257
+ new label and expect long-lived streams to report only when they complete or close. Native
258
+ transport logs are now enabled by default; use the explicit opt-out when an enclosing
259
+ logical operation already owns the warning. Do not add a second observed transport around
260
+ these clients, or each request would be counted twice.
261
+
184
262
  ## Never recorded
185
263
 
186
264
  Keys, URLs, query strings, bodies, message ids, receipts, object names, bucket or queue
@@ -8,15 +8,14 @@ not need to be reimplemented here.
8
8
  ## Layout and public API
9
9
 
10
10
  Group domain clients by capability, with a module per provider even when the package
11
- currently has one provider. HTTP transports are cross-cutting infrastructure and stay at
12
- the root. Tests stay in `tests/test_<provider>.py`.
11
+ currently has one provider. HTTP implementations live under `http/`, with explicit
12
+ imports for optional transports. Tests stay in `tests/test_<provider>.py`.
13
13
 
14
14
  ```text
15
15
  clients_021/
16
16
  email/ # _message.py (shared protocol/types), resend.py, ses.py
17
17
  queue/ # sqs.py, qstash.py
18
- http.py
19
- httpx2.py
18
+ http/ # httpx.py (HttpClient/ObservedTransport), httpx2.py (ObservedTransport)
20
19
  cache/ # redis.py
21
20
  storage/ # s3.py, vercel_blob.py
22
21
  crypto/ # kms.py
@@ -24,7 +23,9 @@ clients_021/
24
23
 
25
24
  The original `clients_021.redis`, `clients_021.s3` and `clients_021.kms` imports remain
26
25
  compatibility re-exports of the same classes and result types. New code uses the capability
27
- paths. Do not remove compatibility modules without a breaking release.
26
+ paths. `clients_021.http` re-exports its existing types from `http.httpx`, and the original
27
+ `clients_021.httpx2` remains an alias for `http.httpx2`. Keep `clients_021.HttpClient`
28
+ available too. Do not remove compatibility modules without a breaking release.
28
29
 
29
30
  Public result and exception types live beside their provider unless they are truly shared.
30
31
  A common protocol requires interchangeable operations and semantics: `EmailSender.send`
@@ -64,7 +65,11 @@ Each implementation must make these decisions explicit in README.md and CONTRACT
64
65
  Use `_observe.observed` for a logical cache, messaging, storage or crypto operation. HTTP-backed
65
66
  clients may also emit the transport's HTTP span/metrics: one logical operation and one
66
67
  network request are different measurements. Do not sum those counters as distinct
67
- business operations. Disable exception recording when provider errors can contain data.
68
+ business operations. Disable nested HTTP attempt logs with `log_failures=False` when the
69
+ logical operation owns the failure log. HTTP transport duration includes raw-body consumption
70
+ and resource close; keep measurement once-only, incremental, and detached from consumer
71
+ context between chunks. Test read/close errors, cancellation (including repeated cancellation
72
+ while closing), early close, and cross-task consumption. Disable exception recording when provider errors can contain data.
68
73
  The safe wrapper message does not make a native chained exception safe to log.
69
74
 
70
75
  Expose small, stable results when the caller needs them. Validate acknowledgements before
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 021-python-clients
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email and KMS, built on 021-python-observability
5
5
  Project-URL: Repository, https://github.com/from021-io/python-clients
6
6
  Project-URL: Changelog, https://github.com/from021-io/python-clients/blob/main/CHANGELOG.md
@@ -48,8 +48,8 @@ uv add "021-python-clients[redis,aws,httpx2,vercel-blob]" # pick the extras yo
48
48
 
49
49
  | Module | Extra | What it gives |
50
50
  | --- | --- | --- |
51
- | `clients_021.http` | none | `HttpClient`: one httpx client per dependency, timeouts, retries on idempotent methods |
52
- | `clients_021.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
51
+ | `clients_021.http.httpx` | none | `HttpClient`: buffered requests, streaming and full-body telemetry; native `ObservedTransport` |
52
+ | `clients_021.http.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
53
53
  | `clients_021.cache.redis` | `redis` | `RedisClient`: JSON with TTL, one-time reads, windowed counters, ping |
54
54
  | `clients_021.queue.qstash` | none | `QStashClient`: JSON publishing, deduplication, delivery options and forwarded trace context |
55
55
  | `clients_021.queue.sqs` | `aws` | `SqsClient`: send, receive, delete, visibility, trace carried in the message |
@@ -66,10 +66,12 @@ Every client takes the service's `Observability` (from `setup_observability`) an
66
66
 
67
67
  Providers with the same purpose live under one capability package: `email/` contains
68
68
  Resend and SES; `queue/` contains SQS and QStash; `storage/` contains S3 and Vercel Blob; `cache/`
69
- contains Redis; `crypto/` contains KMS. HTTP transports remain at the root. Import providers
70
- explicitly:
69
+ contains Redis; `crypto/` contains KMS. `http/` contains the httpx client and httpx2
70
+ transport. Import providers explicitly:
71
71
 
72
72
  ```python
73
+ from clients_021.http import HttpClient
74
+ from clients_021.http.httpx2 import ObservedTransport # requires the httpx2 extra
73
75
  from clients_021.queue.sqs import SqsClient
74
76
  from clients_021.queue.qstash import QStashClient
75
77
  from clients_021.storage.s3 import S3Client
@@ -85,7 +87,9 @@ does not load the optional AWS SDK.
85
87
 
86
88
  The 0.1.0 imports `clients_021.redis`, `clients_021.s3` and `clients_021.kms` remain
87
89
  compatible aliases for the same classes and result types. Importing a capability package
88
- does not load its optional SDKs.
90
+ does not load its optional SDKs. `clients_021.HttpClient` and `clients_021.http` keep their
91
+ published exports; `clients_021.httpx2.ObservedTransport` remains an alias for
92
+ `clients_021.http.httpx2.ObservedTransport`.
89
93
 
90
94
  ## What every call gets
91
95
 
@@ -97,7 +101,8 @@ does not load its optional SDKs.
97
101
  `crypto.client.operation.*` (KMS), `http.client.request.*` (HTTP, and the HTTP request
98
102
  under QStash and Resend). Labels are bounded; see [CONTRACT.md](CONTRACT.md).
99
103
  - Redis, SQS, QStash, S3, Vercel Blob, email and KMS emit a `dependency.call_failed` warning on failure,
100
- with dependency, operation and error type only. HTTP transports emit spans and metrics.
104
+ with dependency, operation and error type only. HTTP/httpx2 also log failed attempts,
105
+ including body failures and cancellation; QStash/Resend suppress the nested HTTP log.
101
106
  - `DependencyUnavailable(dependency, operation, reason)` when the service timed out,
102
107
  refused the connection, throttled or failed with a 5xx. Map it to a 503. Errors that mean
103
108
  the request is wrong (access denied, a missing bucket, a wrong Redis type) keep their
@@ -290,21 +295,58 @@ ciphertext keep their botocore type (they are bugs or attacks, not outages); a t
290
295
  envelope raises `cryptography`'s `InvalidTag`. The envelope layout is in
291
296
  [CONTRACT.md](CONTRACT.md).
292
297
 
298
+ ### HTTP bodies and streaming
299
+
300
+ ```python
301
+ from clients_021.http import HttpClient
302
+
303
+ http = HttpClient(obs, timeout_seconds=10)
304
+ try:
305
+ async with http.stream("GET", download_url, dependency="documents") as response:
306
+ response.raise_for_status()
307
+ async for chunk in response.aiter_bytes():
308
+ await write_chunk(chunk)
309
+ finally:
310
+ await http.aclose()
311
+ ```
312
+
313
+ These async clients run on asyncio. `request()` returns a buffered `HttpResponse`; `stream()` yields a native httpx response
314
+ without buffering and accepts an async iterable as upload `content`. Streaming sends once,
315
+ even for GET. The buffered client's configured idempotent retries are unchanged. Close the
316
+ response context before closing the client. For SDKs using native httpx, inject
317
+ `clients_021.http.httpx.ObservedTransport`; httpx2 SDKs use the transport below.
318
+
319
+ Each HTTP attempt emits one span, counter and duration after body EOF and connection close,
320
+ or early close. This includes upload, download, pauses between chunks and cleanup. Body
321
+ errors after 200 headers are recorded as errors. `http.response.body.complete` distinguishes
322
+ EOF from partial/unread close; it does not mean the status or cleanup succeeded. Intentional
323
+ partial close is INFO `dependency.stream_closed`; exceptions, cancellation and HTTP >= 400
324
+ produce WARNING `dependency.call_failed`. No success log, URL, header, body or provider
325
+ error message is emitted. `log_failures=False` disables attempt logs when an enclosing
326
+ operation owns the warning, as QStash and Resend do.
327
+
328
+ These duration semantics and the completion label change the 0.2.x telemetry contract;
329
+ see [migration notes](CONTRACT.md#migration-from-02x) before updating dashboards or alerts.
330
+
293
331
  ### SDKs on httpx2
294
332
 
295
333
  For an SDK on httpx2:
296
334
 
297
335
  ```python
298
336
  from anthropic import AsyncAnthropic, DefaultAsyncHttpxClient
299
- from clients_021.httpx2 import ObservedTransport
337
+ from clients_021.http.httpx2 import ObservedTransport
300
338
 
301
339
  transport = ObservedTransport(obs, dependency="anthropic")
302
340
  claude = AsyncAnthropic(http_client=DefaultAsyncHttpxClient(transport=transport))
303
341
  ```
304
342
 
305
- It does not send `traceparent` to third-party APIs unless `propagate=True`.
343
+ It does not inject trace context into third-party API requests unless `propagate=True`.
306
344
  The transport preserves SDK exceptions so the SDK retains control of retries and error
307
- handling; it does not convert them to `DependencyUnavailable`.
345
+ handling; it does not convert them to `DependencyUnavailable`. Its span stays open through
346
+ incremental response reads and close. Use the SDK's stream context manager, or close manual
347
+ responses explicitly; abandoning a stream leaves both its connection and span open. Caller
348
+ processing keeps its own trace context, including when streams are interleaved or consumed
349
+ in another task.
308
350
 
309
351
  ## Service integration
310
352
 
@@ -338,7 +380,7 @@ async with AsyncExitStack() as resources:
338
380
 
339
381
  Retry policies are explicit:
340
382
 
341
- - HTTP retries GET, HEAD, OPTIONS, PUT and DELETE on network failures, timeouts,
383
+ - Buffered HTTP (`request`) retries GET, HEAD, OPTIONS, PUT and DELETE on network failures, timeouts,
342
384
  remote protocol/proxy connection failures and 502/503/504. `retries=2` means at most
343
385
  three attempts.
344
386
  POST and PATCH run once. Invalid URLs/protocols and invalid request construction keep
@@ -372,10 +414,11 @@ stop an SDK request already running in its thread. Timeouts are per SDK/network
372
414
  not an overall deadline. Limit consumer concurrency and let in-flight work finish before
373
415
  closing the clients. Redis window counters require Redis server 7 or newer (`EXPIRE NX`).
374
416
 
375
- HTTP transport spans end when response headers arrive. Body downloads and SDK streaming
376
- are outside that span; instrument the enclosing service/SDK operation when you need total
377
- latency or failures while consuming the body. S3 `get` buffers the entire object in memory;
378
- use a dedicated streaming adapter for large files.
417
+ HTTP spans now include network body consumption and cleanup. The SDK/application still
418
+ owns content decoding, JSON/SSE interpretation and processing errors outside raw-stream I/O.
419
+ Timeouts remain per I/O, not a total stream deadline. Cancellation waits for shielded body
420
+ cleanup before propagating. Close all responses and consumers before shutdown/flush.
421
+ S3 `get` buffers the entire object in memory; use a dedicated streaming adapter for large files.
379
422
 
380
423
  ## Development
381
424
 
@@ -12,8 +12,8 @@ uv add "021-python-clients[redis,aws,httpx2,vercel-blob]" # pick the extras yo
12
12
 
13
13
  | Module | Extra | What it gives |
14
14
  | --- | --- | --- |
15
- | `clients_021.http` | none | `HttpClient`: one httpx client per dependency, timeouts, retries on idempotent methods |
16
- | `clients_021.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
15
+ | `clients_021.http.httpx` | none | `HttpClient`: buffered requests, streaming and full-body telemetry; native `ObservedTransport` |
16
+ | `clients_021.http.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
17
17
  | `clients_021.cache.redis` | `redis` | `RedisClient`: JSON with TTL, one-time reads, windowed counters, ping |
18
18
  | `clients_021.queue.qstash` | none | `QStashClient`: JSON publishing, deduplication, delivery options and forwarded trace context |
19
19
  | `clients_021.queue.sqs` | `aws` | `SqsClient`: send, receive, delete, visibility, trace carried in the message |
@@ -30,10 +30,12 @@ Every client takes the service's `Observability` (from `setup_observability`) an
30
30
 
31
31
  Providers with the same purpose live under one capability package: `email/` contains
32
32
  Resend and SES; `queue/` contains SQS and QStash; `storage/` contains S3 and Vercel Blob; `cache/`
33
- contains Redis; `crypto/` contains KMS. HTTP transports remain at the root. Import providers
34
- explicitly:
33
+ contains Redis; `crypto/` contains KMS. `http/` contains the httpx client and httpx2
34
+ transport. Import providers explicitly:
35
35
 
36
36
  ```python
37
+ from clients_021.http import HttpClient
38
+ from clients_021.http.httpx2 import ObservedTransport # requires the httpx2 extra
37
39
  from clients_021.queue.sqs import SqsClient
38
40
  from clients_021.queue.qstash import QStashClient
39
41
  from clients_021.storage.s3 import S3Client
@@ -49,7 +51,9 @@ does not load the optional AWS SDK.
49
51
 
50
52
  The 0.1.0 imports `clients_021.redis`, `clients_021.s3` and `clients_021.kms` remain
51
53
  compatible aliases for the same classes and result types. Importing a capability package
52
- does not load its optional SDKs.
54
+ does not load its optional SDKs. `clients_021.HttpClient` and `clients_021.http` keep their
55
+ published exports; `clients_021.httpx2.ObservedTransport` remains an alias for
56
+ `clients_021.http.httpx2.ObservedTransport`.
53
57
 
54
58
  ## What every call gets
55
59
 
@@ -61,7 +65,8 @@ does not load its optional SDKs.
61
65
  `crypto.client.operation.*` (KMS), `http.client.request.*` (HTTP, and the HTTP request
62
66
  under QStash and Resend). Labels are bounded; see [CONTRACT.md](CONTRACT.md).
63
67
  - Redis, SQS, QStash, S3, Vercel Blob, email and KMS emit a `dependency.call_failed` warning on failure,
64
- with dependency, operation and error type only. HTTP transports emit spans and metrics.
68
+ with dependency, operation and error type only. HTTP/httpx2 also log failed attempts,
69
+ including body failures and cancellation; QStash/Resend suppress the nested HTTP log.
65
70
  - `DependencyUnavailable(dependency, operation, reason)` when the service timed out,
66
71
  refused the connection, throttled or failed with a 5xx. Map it to a 503. Errors that mean
67
72
  the request is wrong (access denied, a missing bucket, a wrong Redis type) keep their
@@ -254,21 +259,58 @@ ciphertext keep their botocore type (they are bugs or attacks, not outages); a t
254
259
  envelope raises `cryptography`'s `InvalidTag`. The envelope layout is in
255
260
  [CONTRACT.md](CONTRACT.md).
256
261
 
262
+ ### HTTP bodies and streaming
263
+
264
+ ```python
265
+ from clients_021.http import HttpClient
266
+
267
+ http = HttpClient(obs, timeout_seconds=10)
268
+ try:
269
+ async with http.stream("GET", download_url, dependency="documents") as response:
270
+ response.raise_for_status()
271
+ async for chunk in response.aiter_bytes():
272
+ await write_chunk(chunk)
273
+ finally:
274
+ await http.aclose()
275
+ ```
276
+
277
+ These async clients run on asyncio. `request()` returns a buffered `HttpResponse`; `stream()` yields a native httpx response
278
+ without buffering and accepts an async iterable as upload `content`. Streaming sends once,
279
+ even for GET. The buffered client's configured idempotent retries are unchanged. Close the
280
+ response context before closing the client. For SDKs using native httpx, inject
281
+ `clients_021.http.httpx.ObservedTransport`; httpx2 SDKs use the transport below.
282
+
283
+ Each HTTP attempt emits one span, counter and duration after body EOF and connection close,
284
+ or early close. This includes upload, download, pauses between chunks and cleanup. Body
285
+ errors after 200 headers are recorded as errors. `http.response.body.complete` distinguishes
286
+ EOF from partial/unread close; it does not mean the status or cleanup succeeded. Intentional
287
+ partial close is INFO `dependency.stream_closed`; exceptions, cancellation and HTTP >= 400
288
+ produce WARNING `dependency.call_failed`. No success log, URL, header, body or provider
289
+ error message is emitted. `log_failures=False` disables attempt logs when an enclosing
290
+ operation owns the warning, as QStash and Resend do.
291
+
292
+ These duration semantics and the completion label change the 0.2.x telemetry contract;
293
+ see [migration notes](CONTRACT.md#migration-from-02x) before updating dashboards or alerts.
294
+
257
295
  ### SDKs on httpx2
258
296
 
259
297
  For an SDK on httpx2:
260
298
 
261
299
  ```python
262
300
  from anthropic import AsyncAnthropic, DefaultAsyncHttpxClient
263
- from clients_021.httpx2 import ObservedTransport
301
+ from clients_021.http.httpx2 import ObservedTransport
264
302
 
265
303
  transport = ObservedTransport(obs, dependency="anthropic")
266
304
  claude = AsyncAnthropic(http_client=DefaultAsyncHttpxClient(transport=transport))
267
305
  ```
268
306
 
269
- It does not send `traceparent` to third-party APIs unless `propagate=True`.
307
+ It does not inject trace context into third-party API requests unless `propagate=True`.
270
308
  The transport preserves SDK exceptions so the SDK retains control of retries and error
271
- handling; it does not convert them to `DependencyUnavailable`.
309
+ handling; it does not convert them to `DependencyUnavailable`. Its span stays open through
310
+ incremental response reads and close. Use the SDK's stream context manager, or close manual
311
+ responses explicitly; abandoning a stream leaves both its connection and span open. Caller
312
+ processing keeps its own trace context, including when streams are interleaved or consumed
313
+ in another task.
272
314
 
273
315
  ## Service integration
274
316
 
@@ -302,7 +344,7 @@ async with AsyncExitStack() as resources:
302
344
 
303
345
  Retry policies are explicit:
304
346
 
305
- - HTTP retries GET, HEAD, OPTIONS, PUT and DELETE on network failures, timeouts,
347
+ - Buffered HTTP (`request`) retries GET, HEAD, OPTIONS, PUT and DELETE on network failures, timeouts,
306
348
  remote protocol/proxy connection failures and 502/503/504. `retries=2` means at most
307
349
  three attempts.
308
350
  POST and PATCH run once. Invalid URLs/protocols and invalid request construction keep
@@ -336,10 +378,11 @@ stop an SDK request already running in its thread. Timeouts are per SDK/network
336
378
  not an overall deadline. Limit consumer concurrency and let in-flight work finish before
337
379
  closing the clients. Redis window counters require Redis server 7 or newer (`EXPIRE NX`).
338
380
 
339
- HTTP transport spans end when response headers arrive. Body downloads and SDK streaming
340
- are outside that span; instrument the enclosing service/SDK operation when you need total
341
- latency or failures while consuming the body. S3 `get` buffers the entire object in memory;
342
- use a dedicated streaming adapter for large files.
381
+ HTTP spans now include network body consumption and cleanup. The SDK/application still
382
+ owns content decoding, JSON/SSE interpretation and processing errors outside raw-stream I/O.
383
+ Timeouts remain per I/O, not a total stream deadline. Cancellation waits for shielded body
384
+ cleanup before propagating. Close all responses and consumers before shutdown/flush.
385
+ S3 `get` buffers the entire object in memory; use a dedicated streaming adapter for large files.
343
386
 
344
387
  ## Development
345
388
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "021-python-clients"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email and KMS, built on 021-python-observability"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -3,7 +3,7 @@
3
3
  `clients_021.http`, `clients_021.queue.qstash` and `clients_021.email` (Resend) need no extras;
4
4
  `clients_021.cache.redis` needs the `redis` extra; `clients_021.queue.sqs`,
5
5
  `clients_021.storage.s3`, `clients_021.email.ses` and `clients_021.crypto.kms` the `aws` extra;
6
- `clients_021.httpx2` the `httpx2` extra.
6
+ `clients_021.http.httpx2` the `httpx2` extra.
7
7
  `clients_021.storage.vercel_blob` needs the `vercel-blob` extra.
8
8
  """
9
9
 
@@ -98,7 +98,11 @@ class ResendSender:
98
98
  }
99
99
  inner = transport if transport is not None else httpx.AsyncHTTPTransport()
100
100
  self._http = HttpClient(
101
- obs, timeout_seconds=timeout_seconds, retries=0, transport=_WithoutTraceHeaders(inner)
101
+ obs,
102
+ timeout_seconds=timeout_seconds,
103
+ retries=0,
104
+ log_failures=False,
105
+ transport=_WithoutTraceHeaders(inner),
102
106
  )
103
107
 
104
108
  async def send(self, message: EmailMessage, *, idempotency_key: str | None = None) -> str:
@@ -0,0 +1,11 @@
1
+ """HTTP clients and transports; optional implementations are imported explicitly."""
2
+
3
+ from clients_021.http.httpx import (
4
+ IDEMPOTENT_METHODS,
5
+ RETRY_STATUS,
6
+ HttpClient,
7
+ HttpMethod,
8
+ HttpResponse,
9
+ )
10
+
11
+ __all__ = ["IDEMPOTENT_METHODS", "RETRY_STATUS", "HttpClient", "HttpMethod", "HttpResponse"]