021-python-clients 0.1.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 (57) hide show
  1. 021_python_clients-0.3.0/CHANGELOG.md +87 -0
  2. 021_python_clients-0.3.0/CONTRACT.md +269 -0
  3. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/CONTRIBUTING.md +20 -10
  4. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/PKG-INFO +135 -24
  5. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/README.md +130 -22
  6. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/pyproject.toml +6 -2
  7. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/__init__.py +4 -3
  8. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/_observe.py +1 -1
  9. 021_python_clients-0.3.0/src/clients_021/cache/__init__.py +1 -0
  10. {021_python_clients-0.1.0/src/clients_021 → 021_python_clients-0.3.0/src/clients_021/cache}/redis.py +1 -1
  11. 021_python_clients-0.3.0/src/clients_021/crypto/__init__.py +1 -0
  12. {021_python_clients-0.1.0/src/clients_021 → 021_python_clients-0.3.0/src/clients_021/crypto}/kms.py +1 -1
  13. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/email/resend.py +5 -1
  14. 021_python_clients-0.3.0/src/clients_021/http/__init__.py +11 -0
  15. 021_python_clients-0.3.0/src/clients_021/http/_observe.py +175 -0
  16. 021_python_clients-0.3.0/src/clients_021/http/httpx.py +280 -0
  17. 021_python_clients-0.3.0/src/clients_021/http/httpx2.py +76 -0
  18. 021_python_clients-0.3.0/src/clients_021/httpx2.py +5 -0
  19. 021_python_clients-0.3.0/src/clients_021/kms.py +5 -0
  20. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/queue/qstash.py +1 -1
  21. 021_python_clients-0.3.0/src/clients_021/redis.py +5 -0
  22. 021_python_clients-0.3.0/src/clients_021/s3.py +5 -0
  23. 021_python_clients-0.3.0/src/clients_021/storage/__init__.py +1 -0
  24. {021_python_clients-0.1.0/src/clients_021 → 021_python_clients-0.3.0/src/clients_021/storage}/s3.py +1 -1
  25. 021_python_clients-0.3.0/src/clients_021/storage/vercel_blob.py +169 -0
  26. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_contract.py +4 -4
  27. 021_python_clients-0.3.0/tests/test_http_stream_lifecycle.py +270 -0
  28. 021_python_clients-0.3.0/tests/test_http_streaming.py +482 -0
  29. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_httpx2.py +8 -2
  30. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_kms.py +1 -1
  31. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_redis.py +1 -1
  32. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_s3.py +1 -1
  33. 021_python_clients-0.3.0/tests/test_vercel_blob.py +347 -0
  34. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/wheel_minimal.py +22 -1
  35. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/wheel_smoke.py +26 -6
  36. 021_python_clients-0.1.0/CHANGELOG.md +0 -41
  37. 021_python_clients-0.1.0/CONTRACT.md +0 -163
  38. 021_python_clients-0.1.0/src/clients_021/http.py +0 -131
  39. 021_python_clients-0.1.0/src/clients_021/httpx2.py +0 -93
  40. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/.gitignore +0 -0
  41. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/LICENSE +0 -0
  42. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/_aws.py +0 -0
  43. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/email/__init__.py +0 -0
  44. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/email/_message.py +0 -0
  45. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/email/ses.py +0 -0
  46. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/errors.py +0 -0
  47. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/py.typed +0 -0
  48. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/queue/__init__.py +0 -0
  49. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/src/clients_021/queue/sqs.py +0 -0
  50. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/__init__.py +0 -0
  51. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/conftest.py +0 -0
  52. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/qstash_local.py +0 -0
  53. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_email.py +0 -0
  54. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_http.py +0 -0
  55. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_qstash.py +0 -0
  56. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_ses.py +0 -0
  57. {021_python_clients-0.1.0 → 021_python_clients-0.3.0}/tests/test_sqs.py +0 -0
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes are recorded here by release-please from Conventional Commits.
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
+
21
+ ## [0.2.0](https://github.com/from021-io/python-clients/compare/v0.1.0...v0.2.0) (2026-10-21)
22
+
23
+
24
+ ### Features
25
+
26
+ * **storage:** add observed Vercel Blob client ([9850d03](https://github.com/from021-io/python-clients/commit/9850d0381eed264c5d2962dec1340eb110621480))
27
+ * **storage:** add observed Vercel Blob client ([2cd982d](https://github.com/from021-io/python-clients/commit/2cd982d79b5ebe7e3c5f473c41403803f2803a72))
28
+ * **storage:** add observed Vercel Blob client ([#7](https://github.com/from021-io/python-clients/issues/7)) ([9850d03](https://github.com/from021-io/python-clients/commit/9850d0381eed264c5d2962dec1340eb110621480))
29
+
30
+
31
+ ### Refactors
32
+
33
+ * **clients:** group providers by capability ([56d4d89](https://github.com/from021-io/python-clients/commit/56d4d89ac69f757a059fed5d19a636a16ce417ed))
34
+ * **clients:** group providers by capability ([3bac4c1](https://github.com/from021-io/python-clients/commit/3bac4c14cf1c9b7157e1dec42c6a827d0fc46890))
35
+ * **clients:** group providers by capability ([#6](https://github.com/from021-io/python-clients/issues/6)) ([56d4d89](https://github.com/from021-io/python-clients/commit/56d4d89ac69f757a059fed5d19a636a16ce417ed))
36
+
37
+ ## [Unreleased]
38
+
39
+ ### Added
40
+
41
+ - Add `HttpClient.stream` and a native httpx `ObservedTransport` for incremental bodies with explicit lifetime and no streaming retries.
42
+
43
+ - Add an optional Vercel Blob client with observed CRUD, explicit public/private access, bounded operations and SDK-native metadata/errors.
44
+
45
+ ### Changed
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
+
49
+ - Group Redis, S3 and KMS under cache, storage and crypto, retaining the 0.1.0 imports as compatibility aliases.
50
+
51
+ ## 0.1.0 (2026-10-21)
52
+
53
+
54
+ ### Features
55
+
56
+ * add email (Resend, SES) and KMS clients ([0195195](https://github.com/from021-io/python-clients/commit/019519548c75e6d4bbc50ca564f5eb7dbb2a0ce2))
57
+ * add observed clients for Redis, SQS, S3 and HTTP ([336e53b](https://github.com/from021-io/python-clients/commit/336e53b6a85ec1c84f76100bd5eef1c6cc1f0ba5))
58
+ * **qstash:** add observed JSON publishing and client guide ([9ea3b86](https://github.com/from021-io/python-clients/commit/9ea3b866777d87046a1777ebfc05757d08e3aee9))
59
+ * **qstash:** add observed publishing on top of email and KMS ([cea5d8a](https://github.com/from021-io/python-clients/commit/cea5d8a1a6923be86381a6ef13bd3498956fc909))
60
+ * **queue:** add QStash and group queue providers ([970fbe4](https://github.com/from021-io/python-clients/commit/970fbe474f75866017a5981a66029599bb73964a))
61
+
62
+
63
+ ### Bug fixes
64
+
65
+ * **clients:** enforce error and lifecycle contracts ([170d726](https://github.com/from021-io/python-clients/commit/170d72669d229a9802a1f1eebe41aca43425a4c7))
66
+
67
+
68
+ ### Refactors
69
+
70
+ * **queue:** group SQS and QStash provider modules ([b71f8ba](https://github.com/from021-io/python-clients/commit/b71f8ba2a8ec4c6aadf217cdad83c1a181509964))
71
+
72
+ ## [Unreleased]
73
+
74
+ ### Added
75
+
76
+ - **QStash**: observed JSON publishing with deduplication, delivery options, trace
77
+ propagation, bounded publication timeouts and sanitized provider errors.
78
+ - **Client conventions**: contributor guidance for API design, retries, lifecycle,
79
+ telemetry, privacy and verification of new clients.
80
+
81
+ ### Changed
82
+
83
+ - **Queue organization**: group SQS and QStash under `clients_021.queue.sqs` and
84
+ `clients_021.queue.qstash`, following the capability layout used by email. Provider APIs
85
+ and optional dependencies are unchanged; update imports from the initial draft paths.
86
+
87
+ - **HTTP**: accept raw byte content for JSON `null` and reject conflicting body formats.
@@ -0,0 +1,269 @@
1
+ # Contract
2
+
3
+ What dashboards, alerts and services may rely on. Changing a name, a label or the error
4
+ semantics below is a breaking change.
5
+
6
+ ## Imports and capability packages
7
+
8
+ Queue providers live in `clients_021.queue.sqs` (`SqsClient`, `ReceivedMessage`) and
9
+ `clients_021.queue.qstash` (`QStashClient`, `PublishedMessage`, `QStashResponseError`).
10
+ Use these imports in place of the initial draft's root-level `clients_021.sqs` and
11
+ `clients_021.qstash` paths. These capability paths shipped in 0.1.0.
12
+ Importing `clients_021.queue` or its QStash module does not require the `aws` extra.
13
+
14
+ The queue package groups providers by purpose. SQS owns polling, receipts and visibility;
15
+ QStash owns HTTP delivery. Their public methods and observability contracts remain distinct.
16
+ Shared email types and the interchangeable `EmailSender` protocol live in `clients_021.email`.
17
+
18
+ Redis lives in `clients_021.cache.redis`, S3 in `clients_021.storage.s3` and KMS in
19
+ `clients_021.crypto.kms`. The 0.1.0 root modules (`redis`, `s3`, `kms`) continue to re-export
20
+ the same clients and result types (`ObjectInfo`, `DataKey`). Existing call signatures,
21
+ metric/span names and error semantics are unchanged. Capability package imports do not
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.
27
+
28
+ ## Metrics
29
+
30
+ All on the service's `Observability` meter, so they carry its resource (service name,
31
+ version, environment). Durations use 021-python-observability's buckets.
32
+
33
+ | Instrument | Type / unit | Labels | Clients |
34
+ | --- | --- | --- | --- |
35
+ | `db.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | Redis (shared with SQL from 021-python-observability, which has no `operation`) |
36
+ | `db.client.operation.duration` | Histogram / `s` | same | Redis |
37
+ | `messaging.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | SQS, QStash, email (Resend, SES) |
38
+ | `messaging.client.operation.duration` | Histogram / `s` | same | SQS, QStash, email (Resend, SES) |
39
+ | `storage.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | S3, Vercel Blob |
40
+ | `storage.client.operation.duration` | Histogram / `s` | same | S3, Vercel Blob |
41
+ | `crypto.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | KMS |
42
+ | `crypto.client.operation.duration` | Histogram / `s` | same | KMS |
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) |
44
+ | `http.client.request.duration` | Histogram / `s` | same | HTTP, httpx2, QStash transport, Resend (its HTTP request) |
45
+
46
+ - `status`: `ok` or `error`. `error.type` only on errors.
47
+ - `operation`: a fixed set per client. Redis: `get_json`, `set_json`, `take_json`,
48
+ `delete`, `incr_window`, `ping`. SQS: `send`, `receive`, `delete`, `change_visibility`.
49
+ S3 and Vercel Blob: `put`, `get`, `head`, `delete`. QStash: `publish`.
50
+ Email (Resend, SES): `send`. KMS:
51
+ `encrypt`, `decrypt`, `generate_data_key`; `seal` and `unseal` record the KMS call they make
52
+ (`generate_data_key`, `decrypt`), not the local AES work.
53
+ - Redis/SQS/S3/Vercel Blob/QStash/email/KMS `error.type`: `timeout`, `connection_error`, `throttled`,
54
+ `status_<5xx>`, `cancelled`, or the exception class name for errors that are not outages
55
+ (`EmailRejected` for every email rejection; botocore's class, such as `ClientError` or
56
+ `NotFoundException`, for KMS).
57
+ - Resend records two levels: the `send` operation (`messaging.client.operation.*`) and,
58
+ inside it, its HTTP request (`http.client.request.*`), both under the same `dependency`.
59
+ - HTTP transport metrics use the bounded labels (`timeout`, `cancelled`,
60
+ `connection_error` or `internal_error`). These describe the transport exception, while
61
+ `DependencyUnavailable.reason` describes the failure returned to the caller.
62
+ - httpx2 uses `timeout`, `connection_error`, `cancelled`, or the exception class name.
63
+ HTTP status errors set the span's ERROR status and status-code label, with no `error.type`.
64
+ - A missing S3/Blob object or Redis key is `status=ok`.
65
+ - `dependency` is a static identifier, validated consistently with 021-python-observability.
66
+ User ids, tenant ids and request-derived values are forbidden even if they match its syntax.
67
+
68
+ ## Spans
69
+
70
+ CLIENT spans named `<dependency> <operation>` (`HTTP <dependency>` for HTTP), with the
71
+ metric labels plus `db.system.name=redis`, `messaging.system=aws_sqs`,
72
+ `messaging.system=upstash_qstash`, `messaging.system=resend`, `messaging.system=aws_ses`, or
73
+ `rpc.system=aws-api` + `rpc.service=S3` / `rpc.service=KMS`, or `rpc.system=vercel-blob`.
74
+ Resend's `HTTP <dependency>` span is a child of its `<dependency> send` span. Status ERROR on failure (and on HTTP
75
+ status ≥ 400).
76
+ Cancellation propagates unchanged and records ERROR with `error.type=cancelled`.
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.
92
+
93
+ QStash adds one logical `publish` operation around the HTTP transport request. The
94
+ messaging duration includes reading and validating the acknowledgement; the nested HTTP
95
+ span includes reading and closing the response body. Both use the configured static dependency name.
96
+ Local input validation occurs before either operation is recorded.
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
+
130
+ ## Trace propagation
131
+
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.
137
+ When enabled, both caller-supplied trace headers are replaced with the current context.
138
+ - Resend: nothing is sent. The `traceparent`, `tracestate` and `x-request-id` headers that
139
+ the HTTP transport adds are removed before the request leaves.
140
+ - SES, KMS and Vercel Blob: nothing is sent by the default transports.
141
+ - SQS: `traceparent` and `tracestate` are message attributes; `ReceivedMessage.trace_context()`
142
+ returns the producer's context. Caller-supplied values for both trace attributes are
143
+ replaced with the current context.
144
+ - QStash: the `publish` span's context is sent as `Upstash-Forward-traceparent` and
145
+ `Upstash-Forward-tracestate` for delivery to the consumer. `propagate=False` removes
146
+ both forwarded trace headers. Caller values never override them. The request to QStash
147
+ itself still uses the HTTP transport's propagation policy.
148
+
149
+ ## Errors
150
+
151
+ `DependencyUnavailable(dependency, operation, reason)` with `reason` one of `timeout`,
152
+ `connection_error`, `throttled`, `status_<5xx>`. Raised only when retrying later may work.
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.
157
+ HTTP local protocol/unsupported protocol errors and Redis authentication
158
+ or authorization errors retain their native type and are not treated as outages.
159
+
160
+ QStash maps HTTP 429 to `throttled`, 5xx to `status_<code>`, and transport outages to
161
+ `timeout` or `connection_error`, with operation `publish`. Other non-2xx responses raise
162
+ `QStashResponseError(status_code, reason="rejected")`. A 2xx response must contain a
163
+ non-empty `messageId` and, if present, a boolean `deduplicated`; otherwise it raises
164
+ `QStashResponseError` with `reason="invalid_response"`. A missing deduplication flag
165
+ is inferred from status 202. Neither error includes the provider response body.
166
+ `PublishedMessage(message_id, deduplicated)` represents acceptance, not completed delivery.
167
+
168
+ The exception's message and fields contain only the static dependency, operation and reason.
169
+ The chained provider exception is preserved for debugging and is not sanitized.
170
+
171
+ Email: `EmailRejected(dependency, status, code)` when the provider refused the message or
172
+ the credentials, for both senders. `status` is the HTTP status; `code` is Resend's error
173
+ `name` or the SES error code, reduced to `unknown` unless it is a short identifier. Its
174
+ message is `<dependency> send: rejected (<status> <code>)`; never the provider's message.
175
+ Resend: 429 is `throttled`, 5xx is `status_<5xx>`, transport timeouts and connection
176
+ failures are `timeout` / `connection_error`; every other non-2xx is `EmailRejected` (409
177
+ `concurrent_idempotent_requests` included: the first request is still running). SES: the
178
+ shared AWS classification, and every other `ClientError` becomes `EmailRejected` chained to
179
+ it. Resend's request is sent once; `idempotency_key` becomes its `Idempotency-Key` header
180
+ (1–256 characters, kept by Resend for 24 hours). SES has no idempotency key: the argument is
181
+ validated and ignored.
182
+
183
+ KMS: throttling, 5xx (`KMSInternalException`, `DependencyTimeoutException`,
184
+ `KeyUnavailableException`), timeouts and connection failures are `DependencyUnavailable`.
185
+ Access denied, disabled or missing keys, invalid key state, a wrong encryption context or a
186
+ tampered ciphertext (`InvalidCiphertextException`) and a ciphertext from another key
187
+ (`IncorrectKeyException`) keep their botocore type. `decrypt` always names the client's key.
188
+ Every call requires a non-empty encryption context. `unseal` raises `ValueError` for a
189
+ malformed envelope and `cryptography.exceptions.InvalidTag` when the payload was altered.
190
+
191
+ Vercel Blob (`clients_021.storage.vercel_blob`): HTTP 429 maps to `throttled`; 5xx to
192
+ `status_<code>`; HTTP timeouts and total deadlines to `timeout`; network/remote protocol
193
+ failures to `connection_error`. Known transport causes wrapped by the SDK are classified
194
+ without inspecting provider messages. Other errors retain their SDK type, including
195
+ `BlobAccessError`, `BlobStoreNotFoundError`, `BlobPreconditionFailedError`, and
196
+ `BlobStreamError` for malformed acknowledgements. Only `BlobNotFoundError` becomes `None`
197
+ for `get`/`head`, counted as success. `put` returns the SDK's `PutResult` (exported as
198
+ `BlobUpload`); `head` returns its `HeadResult` (`BlobInfo`). These types are provider-specific.
199
+ SDK validation and credential failures can produce an error span but perform no network I/O.
200
+
201
+ ## Sealed envelope format
202
+
203
+ `KmsClient.seal` output is stored data, so its layout is part of this contract: version
204
+ byte `0x01`, wrapped data key length (2 bytes, big endian), the wrapped key (KMS
205
+ `GenerateDataKey`, `AES_256`, same encryption context), a 12-byte random nonce, then the
206
+ AES-256-GCM ciphertext and 16-byte tag. The GCM additional data is the header (version,
207
+ length and wrapped key) followed by the encryption context as compact JSON with sorted keys.
208
+ A new data key is generated for every envelope.
209
+
210
+ ## Lifecycle and retry limits
211
+
212
+ HTTP, Redis, SQS, QStash, S3, Vercel Blob, Resend, SES and KMS expose `aclose()`. Close them after
213
+ in-flight calls complete; the wrapper owns the underlying client, including one supplied
214
+ through the constructor.
215
+ AWS factories configure three total attempts; injected clients retain their own policy.
216
+ Cancellation of an AWS await does not cancel its worker thread or undo a remote write.
217
+ A transient failure does not guarantee that replaying a non-idempotent operation is safe.
218
+
219
+ QStash publishes once, never automatically replays a POST, and preserves the required
220
+ deduplication key exactly. Keys containing `:` fail locally, matching the provider
221
+ restriction without lossy normalization. Delivery retries, delay and delivery timeout are separate
222
+ provider options. The publication timeout is mandatory and finite. Consumers remain
223
+ responsible for idempotency and signature verification. A closed QStash client cannot
224
+ publish again.
225
+
226
+ `HttpClient.request` and `HttpClient.stream` accept at most one of `json`, `form` or raw byte `content`. Passing
227
+ more than one non-None body format raises `ValueError` before sending a request.
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
+
242
+ Vercel Blob uses the optional `vercel-blob>=0.5,<0.6` SDK and explicit read-write credentials,
243
+ store ID and access mode. It owns a shared transport; each operation borrows it in a separate
244
+ SDK session. No environment credentials or global SDK client perform requests. The total
245
+ operation deadline defaults to 10 seconds and includes buffered downloads. Timeout and
246
+ cancellation do not imply a remote write was undone. Neither this wrapper nor the SDK
247
+ retries operations; an injected transport must respect this policy. Overwrite and random
248
+ suffixes default to false. Callers decide whether a repeated write is safe. A closed client
249
+ cannot reopen its pool. No additional HTTP metrics are emitted by this client.
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
+
262
+ ## Never recorded
263
+
264
+ Keys, URLs, query strings, bodies, message ids, receipts, object names, bucket or queue
265
+ names, provider error messages. Email: sender, recipient and reply-to addresses, subjects,
266
+ text and HTML bodies, tags, idempotency keys, API keys, configuration set names. KMS: key
267
+ ids, ARNs and aliases, encryption contexts, plaintext, ciphertext and data keys.
268
+
269
+ Vercel Blob: read-write tokens, store IDs, pathnames, delivery URLs, content and ETags.
@@ -7,25 +7,31 @@ not need to be reimplemented here.
7
7
 
8
8
  ## Layout and public API
9
9
 
10
- Group providers by capability when more than one serves the same purpose, with a module
11
- per provider inside that package. Keep standalone clients at the root until a second
12
- provider makes grouping useful. Tests stay in `tests/test_<provider>.py`.
10
+ Group domain clients by capability, with a module per provider even when the package
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
- redis.py
20
- s3.py
21
- kms.py
18
+ http/ # httpx.py (HttpClient/ObservedTransport), httpx2.py (ObservedTransport)
19
+ cache/ # redis.py
20
+ storage/ # s3.py, vercel_blob.py
21
+ crypto/ # kms.py
22
22
  ```
23
23
 
24
+ The original `clients_021.redis`, `clients_021.s3` and `clients_021.kms` imports remain
25
+ compatibility re-exports of the same classes and result types. New code uses the capability
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.
29
+
24
30
  Public result and exception types live beside their provider unless they are truly shared.
25
31
  A common protocol requires interchangeable operations and semantics: `EmailSender.send`
26
32
  qualifies. SQS polling/receipts and QStash HTTP delivery do not share a consumer API, so
27
- `queue/` groups them without inventing one. Provider imports are explicit, and importing
28
- the capability package must not load optional SDKs.
33
+ `queue/` groups them without inventing one. S3 and Blob also keep provider-specific metadata
34
+ and write options. Provider imports are explicit, and importing the capability package must not load optional SDKs.
29
35
 
30
36
  Use private helpers for actual shared behavior: `_observe.py` owns instrumentation and
31
37
  `_aws.py` owns AWS configuration and error classification. Avoid a universal base class:
@@ -59,7 +65,11 @@ Each implementation must make these decisions explicit in README.md and CONTRACT
59
65
  Use `_observe.observed` for a logical cache, messaging, storage or crypto operation. HTTP-backed
60
66
  clients may also emit the transport's HTTP span/metrics: one logical operation and one
61
67
  network request are different measurements. Do not sum those counters as distinct
62
- 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.
63
73
  The safe wrapper message does not make a native chained exception safe to log.
64
74
 
65
75
  Expose small, stable results when the caller needs them. Validate acknowledgements before