021-python-clients 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
@@ -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
 
@@ -1,9 +1,8 @@
1
- clients_021/__init__.py,sha256=sF_9ZsxPumQbZ-9iCXoLP8JcU_tITDT6Xk1mXF9PVuQ,816
1
+ clients_021/__init__.py,sha256=to7x0AyCtQxhss2a4YGj1GMJq8NyRQdKp-DICQrBbzs,821
2
2
  clients_021/_aws.py,sha256=sj2kCfod-vz9J93UyeVzNVLGK3M25yipKdW15KyIkw4,1722
3
3
  clients_021/_observe.py,sha256=Xjg5Qck-e8Gt5NQHOND6nzr7DvTn46PmBdccubiD3Dk,3792
4
4
  clients_021/errors.py,sha256=GS_fN8mjr7MCOkhqPhH6LweIvQ20Vd29U-aZY8zBeJQ,972
5
- clients_021/http.py,sha256=-AsGLfngd-eEJnTuZEPdVBI9myYiuggmnrvjQjaMpT8,5045
6
- clients_021/httpx2.py,sha256=w248IcEtDZtsEDkkvj4Bpfl7MU7IJ08EHqHWhryC_3k,3880
5
+ clients_021/httpx2.py,sha256=rNbNGJuPJPZUwVbNN5L88IF0g3Tq6EHqj_uFmJ3RmLQ,161
7
6
  clients_021/kms.py,sha256=YcZXURpqIZTkjcP0fpWMD6X_adMAYwN7zAJNhUImR4s,183
8
7
  clients_021/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
8
  clients_021/redis.py,sha256=xC3tji_ldJAwaBntkZTuPhNHLIzl3F3nDzkikMrbJCc,169
@@ -14,15 +13,19 @@ clients_021/crypto/__init__.py,sha256=qESFBPQBKcHhdFXqeJzSyEP41yveG7rFrxnpITmqlN
14
13
  clients_021/crypto/kms.py,sha256=My5Xyg0bOiT9YHYWojQSszpREEh3bSYMMB26wy6fNEI,7261
15
14
  clients_021/email/__init__.py,sha256=ivmdu9hhNDwPqBn3G7uNG0Zpc3DEMnlnWBVVoVQ-fQA,790
16
15
  clients_021/email/_message.py,sha256=ulTX2cXPxSVaq_5FVF0e6glePVJ7wiO5P3M4WUWN3N0,3258
17
- clients_021/email/resend.py,sha256=ugENkEzw9eUlYih3BZcCVQswTF1MDikPgZyuqdapCjc,5347
16
+ clients_021/email/resend.py,sha256=1EjvEbNbbVDQs5TZfYuZrqh14io8cb5ADneNA63DQbg,5416
18
17
  clients_021/email/ses.py,sha256=0ukHvuVE0nTU5mYWRy2EaHY5v9dUwi4GSKES93pjqBg,4668
18
+ clients_021/http/__init__.py,sha256=P4OmYruhkQfJwJzHA90FXKWg_A2rh5iOl-ES_NTMMVQ,311
19
+ clients_021/http/_observe.py,sha256=hpTzcej9MhlvM-RgUZWicR57ulE90GmZMs5U_qFoquQ,6922
20
+ clients_021/http/httpx.py,sha256=gvqSpO1rb6nBA5i01W1yos5mPBwLqa3-HDYwMtQXc6s,10627
21
+ clients_021/http/httpx2.py,sha256=fmqH4bikDKZ6YY0sNBHZbocff2zU40Gpks1aoRuOGeQ,2810
19
22
  clients_021/queue/__init__.py,sha256=CesweOrXmX5cyU-mRvqu-4wLkNJI15zi_Lows6SPupY,338
20
- clients_021/queue/qstash.py,sha256=j3vz_S9-ed0YQZCaWwQg5wPwf8Bv5P1voNsx8WD8Zhc,8315
23
+ clients_021/queue/qstash.py,sha256=NndlqmNkcBgs0ExGsT-3-bMAdU0skO8Djptqj8NoJAM,8335
21
24
  clients_021/queue/sqs.py,sha256=jnccgbxyglzs2mR_DsH04IisEwygDlw6s3RxR0UtMTc,6693
22
25
  clients_021/storage/__init__.py,sha256=KrJViRHBK7PkIYnagg7vyXp95SzMFp9cUCKEnMeFr84,78
23
26
  clients_021/storage/s3.py,sha256=NDtG9QzHSXvrcazTYTq1EH5VjuRd2xMMsMvaumqci4Q,5946
24
27
  clients_021/storage/vercel_blob.py,sha256=FlnoKiahqtk3PpLsGpyu_Gy87otg15i4BlJZsIB3LfM,6511
25
- 021_python_clients-0.2.0.dist-info/METADATA,sha256=gx7Jt2wjPNVFhyUHCze-tQ5YVu-Kqrt2GwAyia6HdXs,20595
26
- 021_python_clients-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
27
- 021_python_clients-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
28
- 021_python_clients-0.2.0.dist-info/RECORD,,
28
+ 021_python_clients-0.3.0.dist-info/METADATA,sha256=ewZEC_EJC4-e8ZayWgpdlnxQ0hYhWMMjmRr2KIN4iW4,23227
29
+ 021_python_clients-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
30
+ 021_python_clients-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
31
+ 021_python_clients-0.3.0.dist-info/RECORD,,
clients_021/__init__.py CHANGED
@@ -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"]
@@ -0,0 +1,175 @@
1
+ """One observation per HTTP attempt, owned by its response stream until close.
2
+
3
+ Never leave an OpenTelemetry context attached across a yielded chunk: consumers may
4
+ read/close in another task, or interleave independent streams in the same task.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import sys
11
+ import time
12
+ from collections.abc import AsyncIterator, Callable, MutableMapping
13
+ from contextlib import AbstractContextManager, suppress
14
+ from typing import Protocol
15
+
16
+ import structlog.contextvars
17
+ from observability_021 import Observability, get_logger
18
+ from opentelemetry.trace import Span, SpanKind, StatusCode, use_span
19
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
20
+
21
+ _METHODS = frozenset({"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"})
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def propagate(headers: MutableMapping[str, str], *, request_id: bool = False) -> None:
26
+ # HTTP Headers are case insensitive. Replace the entire trace pair, including stale state.
27
+ headers.pop("traceparent", None)
28
+ headers.pop("tracestate", None)
29
+ carrier: dict[str, str] = {}
30
+ TraceContextTextMapPropagator().inject(carrier)
31
+ headers.update(carrier)
32
+ correlation = structlog.contextvars.get_contextvars().get("request_id")
33
+ if request_id and isinstance(correlation, str) and correlation:
34
+ headers["x-request-id"] = correlation
35
+
36
+
37
+ class Observation:
38
+ def __init__(
39
+ self, obs: Observability, dependency: str, method: str, *, log_failures: bool
40
+ ) -> None:
41
+ self.obs = obs
42
+ self.dependency = dependency
43
+ self.method = method if method in _METHODS else "_OTHER"
44
+ self.attributes: dict[str, str | int | bool] = {
45
+ "dependency": dependency,
46
+ "http.request.method": self.method,
47
+ }
48
+ self.started = time.perf_counter()
49
+ self.span = obs.tracer.start_span(
50
+ f"HTTP {dependency}", kind=SpanKind.CLIENT, attributes=self.attributes
51
+ )
52
+ self.status_code: int | None = None
53
+ self.log_failures = log_failures
54
+ self.finished = False
55
+
56
+ def activate(self) -> AbstractContextManager[Span]:
57
+ return use_span(self.span, record_exception=False, set_status_on_exception=False)
58
+
59
+ def finish(self, *, complete: bool = False, error_type: str | None = None) -> None:
60
+ if self.finished:
61
+ return
62
+ self.finished = True
63
+ self.attributes["http.response.body.complete"] = complete
64
+ if self.status_code is not None:
65
+ self.attributes["http.response.status_code"] = self.status_code
66
+ if error_type is not None:
67
+ self.attributes["error.type"] = error_type
68
+ rejected = self.status_code is not None and self.status_code >= 400
69
+ if error_type is not None or rejected:
70
+ self.span.set_status(StatusCode.ERROR)
71
+ with self.activate():
72
+ try:
73
+ self.span.set_attributes(self.attributes)
74
+ if self.log_failures:
75
+ if error_type is not None or rejected:
76
+ logger.warning(
77
+ "dependency.call_failed",
78
+ dependency=self.dependency,
79
+ operation=self.method,
80
+ error_type=error_type or f"status_{self.status_code}",
81
+ )
82
+ elif not complete:
83
+ logger.info(
84
+ "dependency.stream_closed",
85
+ dependency=self.dependency,
86
+ operation=self.method,
87
+ )
88
+ self.obs.client_requests.add(1, self.attributes)
89
+ self.obs.client_duration.record(time.perf_counter() - self.started, self.attributes)
90
+ finally:
91
+ self.span.end()
92
+
93
+
94
+ class ByteStream(Protocol):
95
+ def __aiter__(self) -> AsyncIterator[bytes]: ...
96
+ async def aclose(self) -> None: ...
97
+
98
+
99
+ class ObservedBody:
100
+ """SDK-specific subclasses only supply their AsyncByteStream marker type."""
101
+
102
+ def __init__(
103
+ self,
104
+ stream: ByteStream,
105
+ observation: Observation,
106
+ classify: Callable[[BaseException], str],
107
+ ) -> None:
108
+ self._stream = stream
109
+ self._observation = observation
110
+ self._classify = classify
111
+ self._iterator: AsyncIterator[bytes] | None = None
112
+ self._complete = False
113
+ self._closed = False
114
+ self._error: str | None = None
115
+ self._close_task: asyncio.Task[None] | None = None
116
+
117
+ def __aiter__(self) -> AsyncIterator[bytes]:
118
+ return self
119
+
120
+ def fail(self, error: BaseException) -> None:
121
+ if self._error is None:
122
+ self._error = self._classify(error)
123
+
124
+ async def __anext__(self) -> bytes:
125
+ if self._closed:
126
+ raise StopAsyncIteration
127
+ try:
128
+ with self._observation.activate():
129
+ if self._iterator is None:
130
+ self._iterator = self._stream.__aiter__()
131
+ return await anext(self._iterator)
132
+ except StopAsyncIteration:
133
+ self._complete = True
134
+ await self.aclose()
135
+ raise
136
+ except BaseException as error:
137
+ self.fail(error)
138
+ # A cleanup failure must not replace the original read error/cancellation.
139
+ with suppress(BaseException):
140
+ await self.aclose()
141
+ raise
142
+
143
+ async def aclose(self) -> None:
144
+ if self._observation.finished:
145
+ return
146
+ self._closed = True
147
+ # Cancellation between chunks reaches aclose through the consumer's context manager.
148
+ current_error = sys.exception()
149
+ if isinstance(current_error, asyncio.CancelledError):
150
+ self.fail(current_error)
151
+ cancelled: asyncio.CancelledError | None = None
152
+ with self._observation.activate():
153
+ if self._close_task is None:
154
+ self._close_task = asyncio.create_task(self._stream.aclose())
155
+ try:
156
+ while True:
157
+ try:
158
+ await asyncio.shield(self._close_task)
159
+ break
160
+ except asyncio.CancelledError as error:
161
+ self.fail(error)
162
+ cancelled = cancelled or error
163
+ if self._close_task.done():
164
+ break
165
+ self._close_task.result()
166
+ except BaseException as error:
167
+ self.fail(error)
168
+ if cancelled is None:
169
+ if isinstance(current_error, asyncio.CancelledError):
170
+ raise current_error from None
171
+ raise
172
+ finally:
173
+ self._observation.finish(complete=self._complete, error_type=self._error)
174
+ if cancelled is not None:
175
+ raise cancelled
@@ -0,0 +1,280 @@
1
+ """Outbound HTTP: one httpx client per dependency, mandatory timeouts and bounded retries.
2
+
3
+ from clients_021.http import HttpClient
4
+
5
+ http = HttpClient(obs, timeout_seconds=10)
6
+ response = await http.request("GET", url, dependency="identity-provider")
7
+
8
+ Every attempt records `http.client.request.*` and a CLIENT span through response body
9
+ and stream close under the `dependency` name, with `traceparent` / `x-request-id` sent along.
10
+ Buffered methods in IDEMPOTENT_METHODS retry on transport errors and 502/503/504 with
11
+ backoff; POST and PATCH are sent once (send an Idempotency-Key and retry in the caller if
12
+ the other side supports it). A transport failure after the last attempt raises
13
+ DependencyUnavailable; any HTTP status is returned to the caller to judge.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json as jsonlib
20
+ import math
21
+ from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Mapping
22
+ from contextlib import asynccontextmanager
23
+ from dataclasses import dataclass
24
+ from typing import Any, Literal
25
+
26
+ import httpx
27
+ from observability_021 import Observability
28
+ from observability_021.config import identifier
29
+
30
+ from clients_021.errors import DependencyUnavailable
31
+ from clients_021.http._observe import Observation, ObservedBody, propagate
32
+
33
+ HttpMethod = Literal["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]
34
+ # Safe to send twice (RFC 9110, section 9.2.2): the only methods retried.
35
+ IDEMPOTENT_METHODS: frozenset[HttpMethod] = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
36
+ RETRY_STATUS = frozenset({502, 503, 504})
37
+ _OUTAGE_ERRORS = (
38
+ httpx.TimeoutException,
39
+ httpx.NetworkError,
40
+ httpx.RemoteProtocolError,
41
+ httpx.ProxyError,
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class HttpResponse:
47
+ status_code: int
48
+ headers: Mapping[str, str]
49
+ body: bytes
50
+
51
+ def json(self) -> Any:
52
+ return jsonlib.loads(self.body)
53
+
54
+
55
+ def _classify(error: BaseException) -> str:
56
+ if isinstance(error, asyncio.CancelledError):
57
+ return "cancelled"
58
+ if isinstance(error, httpx.TimeoutException):
59
+ return "timeout"
60
+ if isinstance(error, httpx.NetworkError | httpx.RemoteProtocolError | httpx.ProxyError):
61
+ return "connection_error"
62
+ return "internal_error"
63
+
64
+
65
+ class _Body(ObservedBody, httpx.AsyncByteStream):
66
+ pass
67
+
68
+
69
+ class ObservedTransport(httpx.AsyncBaseTransport):
70
+ """Full-body HTTP telemetry for a native httpx.AsyncClient, without retries."""
71
+
72
+ def __init__(
73
+ self,
74
+ obs: Observability,
75
+ *,
76
+ dependency: str,
77
+ transport: httpx.AsyncBaseTransport | None = None,
78
+ propagate: bool = True,
79
+ log_failures: bool = True,
80
+ ) -> None:
81
+ self._obs = obs
82
+ self._dependency = identifier(dependency)
83
+ self._transport = transport if transport is not None else httpx.AsyncHTTPTransport()
84
+ self._propagate = propagate
85
+ self._log_failures = log_failures
86
+
87
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
88
+ observation = Observation(
89
+ self._obs, self._dependency, request.method, log_failures=self._log_failures
90
+ )
91
+ try:
92
+ with observation.activate():
93
+ if self._propagate:
94
+ propagate(request.headers, request_id=True)
95
+ response = await self._transport.handle_async_request(request)
96
+ observation.status_code = response.status_code
97
+ if not isinstance(response.stream, httpx.AsyncByteStream):
98
+ raise TypeError("async transport returned a synchronous response stream")
99
+ if response.is_closed:
100
+ # Custom/mock transports may have already consumed and closed the response.
101
+ try:
102
+ _ = response.content
103
+ except httpx.ResponseNotRead:
104
+ observation.finish(complete=False)
105
+ else:
106
+ observation.finish(complete=True)
107
+ else:
108
+ response.stream = _Body(response.stream, observation, _classify)
109
+ return response
110
+ except BaseException as error:
111
+ observation.finish(error_type=_classify(error))
112
+ raise
113
+
114
+ async def aclose(self) -> None:
115
+ await self._transport.aclose()
116
+
117
+
118
+ class _BorrowedTransport(httpx.AsyncBaseTransport):
119
+ """Dependency-specific clients borrow the injected transport; HttpClient owns it."""
120
+
121
+ def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
122
+ self._transport = transport
123
+
124
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
125
+ return await self._transport.handle_async_request(request)
126
+
127
+ async def aclose(self) -> None:
128
+ pass
129
+
130
+
131
+ class _ClientBody(httpx.AsyncByteStream):
132
+ """Convert only errors from this response's I/O, never consumer exceptions."""
133
+
134
+ def __init__(self, stream: httpx.AsyncByteStream, dependency: str, method: str) -> None:
135
+ self._stream = stream
136
+ self._dependency = dependency
137
+ self._method = method
138
+
139
+ async def __aiter__(self) -> AsyncIterator[bytes]:
140
+ try:
141
+ async for chunk in self._stream:
142
+ yield chunk
143
+ except _OUTAGE_ERRORS as error:
144
+ raise DependencyUnavailable(self._dependency, self._method, _classify(error)) from error
145
+
146
+ async def aclose(self) -> None:
147
+ try:
148
+ await self._stream.aclose()
149
+ except _OUTAGE_ERRORS as error:
150
+ raise DependencyUnavailable(self._dependency, self._method, _classify(error)) from error
151
+
152
+
153
+ class HttpClient:
154
+ def __init__(
155
+ self,
156
+ obs: Observability,
157
+ *,
158
+ timeout_seconds: float,
159
+ retries: int = 2,
160
+ backoff_seconds: float = 0.2,
161
+ log_failures: bool = True,
162
+ transport: httpx.AsyncBaseTransport | None = None,
163
+ ) -> None:
164
+ if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
165
+ raise ValueError("timeout_seconds must be finite and greater than 0")
166
+ if type(retries) is not int or retries < 0:
167
+ raise ValueError("retries must be a non-negative integer")
168
+ if not math.isfinite(backoff_seconds) or backoff_seconds < 0:
169
+ raise ValueError("backoff_seconds must be finite and non-negative")
170
+ self._obs = obs
171
+ self._timeout = httpx.Timeout(timeout_seconds, connect=min(timeout_seconds, 3.0))
172
+ self._retries = retries
173
+ self._backoff = backoff_seconds
174
+ self._transport = transport
175
+ self._log_failures = log_failures
176
+ self._closed = False
177
+ self._clients: dict[str, httpx.AsyncClient] = {}
178
+
179
+ def _client(self, dependency: str) -> httpx.AsyncClient:
180
+ if self._closed:
181
+ raise RuntimeError("HttpClient is closed")
182
+ # One client per dependency, so every call is measured under a fixed name.
183
+ client = self._clients.get(dependency)
184
+ if client is None:
185
+ client = httpx.AsyncClient(
186
+ timeout=self._timeout,
187
+ follow_redirects=False,
188
+ transport=ObservedTransport(
189
+ self._obs,
190
+ dependency=dependency,
191
+ transport=_BorrowedTransport(self._transport)
192
+ if self._transport is not None
193
+ else None,
194
+ log_failures=self._log_failures,
195
+ ),
196
+ )
197
+ self._clients[dependency] = client
198
+ return client
199
+
200
+ async def request(
201
+ self,
202
+ method: HttpMethod,
203
+ url: str,
204
+ *,
205
+ dependency: str,
206
+ headers: Mapping[str, str] | None = None,
207
+ json: Any = None,
208
+ form: Mapping[str, str] | None = None,
209
+ content: bytes | None = None,
210
+ ) -> HttpResponse:
211
+ if sum(value is not None for value in (json, form, content)) > 1:
212
+ raise ValueError("provide only one of json, form or content")
213
+ attempts = 1 + (self._retries if method in IDEMPOTENT_METHODS else 0)
214
+ for attempt in range(attempts):
215
+ last = attempt == attempts - 1
216
+ try:
217
+ response = await self._client(dependency).request(
218
+ method,
219
+ url,
220
+ headers=dict(headers or {}),
221
+ json=json,
222
+ data=dict(form) if form is not None else None,
223
+ content=content,
224
+ )
225
+ except _OUTAGE_ERRORS as error:
226
+ if last:
227
+ raise DependencyUnavailable(dependency, method, _classify(error)) from error
228
+ else:
229
+ if response.status_code not in RETRY_STATUS or last:
230
+ return HttpResponse(
231
+ response.status_code, dict(response.headers), response.content
232
+ )
233
+ await asyncio.sleep(self._backoff * 2**attempt)
234
+ raise AssertionError("unreachable")
235
+
236
+ @asynccontextmanager
237
+ async def stream(
238
+ self,
239
+ method: HttpMethod,
240
+ url: str,
241
+ *,
242
+ dependency: str,
243
+ headers: Mapping[str, str] | None = None,
244
+ json: Any = None,
245
+ form: Mapping[str, str] | None = None,
246
+ content: bytes | AsyncIterable[bytes] | None = None,
247
+ ) -> AsyncGenerator[httpx.Response]:
248
+ """One attempt; yields a native response without buffering or replaying its body."""
249
+ if sum(value is not None for value in (json, form, content)) > 1:
250
+ raise ValueError("provide only one of json, form or content")
251
+ client = self._client(dependency)
252
+ request = client.build_request(
253
+ method,
254
+ url,
255
+ headers=dict(headers or {}),
256
+ json=json,
257
+ data=dict(form) if form is not None else None,
258
+ content=content,
259
+ )
260
+ try:
261
+ response = await client.send(request, stream=True)
262
+ except _OUTAGE_ERRORS as error:
263
+ raise DependencyUnavailable(dependency, method, _classify(error)) from error
264
+ if isinstance(response.stream, httpx.AsyncByteStream):
265
+ response.stream = _ClientBody(response.stream, dependency, method)
266
+ try:
267
+ yield response
268
+ finally:
269
+ await response.aclose()
270
+
271
+ async def aclose(self) -> None:
272
+ if self._closed:
273
+ return
274
+ self._closed = True
275
+ try:
276
+ await asyncio.gather(*(client.aclose() for client in self._clients.values()))
277
+ finally:
278
+ self._clients.clear()
279
+ if self._transport is not None:
280
+ await self._transport.aclose()
@@ -0,0 +1,76 @@
1
+ """Observed async httpx2 transport: each attempt includes response body and stream close.
2
+
3
+ Use ``ObservedTransport(obs, dependency="anthropic")`` with the SDK's HTTP client.
4
+ The SDK keeps control of retries and exceptions. Propagation is opt-in for external APIs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ import httpx2
12
+ from observability_021 import Observability
13
+ from observability_021.config import identifier
14
+
15
+ from clients_021.http._observe import Observation, ObservedBody, propagate
16
+
17
+
18
+ def _classify(error: BaseException) -> str:
19
+ if isinstance(error, asyncio.CancelledError):
20
+ return "cancelled"
21
+ if isinstance(error, httpx2.TimeoutException):
22
+ return "timeout"
23
+ if isinstance(error, httpx2.NetworkError | httpx2.RemoteProtocolError | httpx2.ProxyError):
24
+ return "connection_error"
25
+ return type(error).__name__
26
+
27
+
28
+ class _Body(ObservedBody, httpx2.AsyncByteStream):
29
+ pass
30
+
31
+
32
+ class ObservedTransport(httpx2.AsyncBaseTransport):
33
+ def __init__(
34
+ self,
35
+ obs: Observability,
36
+ *,
37
+ dependency: str,
38
+ transport: httpx2.AsyncBaseTransport | None = None,
39
+ propagate: bool = False,
40
+ log_failures: bool = True,
41
+ ) -> None:
42
+ self._obs = obs
43
+ self._dependency = identifier(dependency)
44
+ self._transport = transport if transport is not None else httpx2.AsyncHTTPTransport()
45
+ self._propagate = propagate
46
+ self._log_failures = log_failures
47
+
48
+ async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
49
+ observation = Observation(
50
+ self._obs, self._dependency, request.method, log_failures=self._log_failures
51
+ )
52
+ try:
53
+ with observation.activate():
54
+ if self._propagate:
55
+ propagate(request.headers)
56
+ response = await self._transport.handle_async_request(request)
57
+ observation.status_code = response.status_code
58
+ if not isinstance(response.stream, httpx2.AsyncByteStream):
59
+ raise TypeError("async transport returned a synchronous response stream")
60
+ if response.is_closed:
61
+ # Custom/mock transports may have already consumed and closed the response.
62
+ try:
63
+ _ = response.content
64
+ except httpx2.ResponseNotRead:
65
+ observation.finish(complete=False)
66
+ else:
67
+ observation.finish(complete=True)
68
+ else:
69
+ response.stream = _Body(response.stream, observation, _classify)
70
+ return response
71
+ except BaseException as error:
72
+ observation.finish(error_type=_classify(error))
73
+ raise
74
+
75
+ async def aclose(self) -> None:
76
+ await self._transport.aclose()
clients_021/httpx2.py CHANGED
@@ -1,93 +1,5 @@
1
- """An observed httpx2 transport, for SDKs built on httpx2 (Anthropic, OpenAI and others).
1
+ """Compatibility import; prefer clients_021.http.httpx2 for new code."""
2
2
 
3
- from anthropic import AsyncAnthropic, DefaultAsyncHttpxClient
4
- from clients_021.httpx2 import ObservedTransport
3
+ from clients_021.http.httpx2 import ObservedTransport
5
4
 
6
- transport = ObservedTransport(obs, dependency="anthropic")
7
- client = AsyncAnthropic(http_client=DefaultAsyncHttpxClient(transport=transport))
8
-
9
- Needs the `httpx2` extra. Records the same `http.client.request.*` instruments and CLIENT
10
- span as 021-python-observability's httpx transport, under `dependency`, with method,
11
- status and a bounded error type; never the URL. Trace context is not sent by default: these
12
- are third-party APIs (`propagate=True` sends `traceparent` to services you own).
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import asyncio
18
- import time
19
-
20
- import httpx2
21
- from observability_021 import Observability
22
- from observability_021.config import identifier
23
- from opentelemetry.trace import SpanKind, StatusCode
24
- from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
25
-
26
- _METHODS = frozenset({"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"})
27
-
28
-
29
- class ObservedTransport(httpx2.AsyncBaseTransport):
30
- def __init__(
31
- self,
32
- obs: Observability,
33
- *,
34
- dependency: str,
35
- transport: httpx2.AsyncBaseTransport | None = None,
36
- propagate: bool = False,
37
- ) -> None:
38
- self._obs = obs
39
- self._dependency = identifier(dependency)
40
- self._transport = transport if transport is not None else httpx2.AsyncHTTPTransport()
41
- self._propagate = propagate
42
-
43
- async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
44
- method = request.method if request.method in _METHODS else "_OTHER"
45
- attributes: dict[str, str | int] = {
46
- "dependency": self._dependency,
47
- "http.request.method": method,
48
- }
49
- started = time.perf_counter()
50
- tracer = self._obs.tracer
51
- with tracer.start_as_current_span(
52
- f"HTTP {self._dependency}",
53
- kind=SpanKind.CLIENT,
54
- attributes=attributes,
55
- record_exception=False,
56
- set_status_on_exception=False,
57
- ) as span:
58
- if self._propagate:
59
- # Replace the whole pair: the new parent may have no tracestate.
60
- request.headers.pop("traceparent", None)
61
- request.headers.pop("tracestate", None)
62
- carrier: dict[str, str] = {}
63
- TraceContextTextMapPropagator().inject(carrier)
64
- for key, value in carrier.items():
65
- request.headers[key] = value
66
- try:
67
- response = await self._transport.handle_async_request(request)
68
- except BaseException as error:
69
- attributes["error.type"] = (
70
- "cancelled"
71
- if isinstance(error, asyncio.CancelledError)
72
- else "timeout"
73
- if isinstance(error, httpx2.TimeoutException)
74
- else "connection_error"
75
- if isinstance(
76
- error, httpx2.NetworkError | httpx2.RemoteProtocolError | httpx2.ProxyError
77
- )
78
- else type(error).__name__
79
- )
80
- span.set_status(StatusCode.ERROR)
81
- raise
82
- else:
83
- attributes["http.response.status_code"] = response.status_code
84
- if response.status_code >= 400:
85
- span.set_status(StatusCode.ERROR)
86
- return response
87
- finally:
88
- span.set_attributes(attributes)
89
- self._obs.client_requests.add(1, attributes)
90
- self._obs.client_duration.record(time.perf_counter() - started, attributes)
91
-
92
- async def aclose(self) -> None:
93
- await self._transport.aclose()
5
+ __all__ = ["ObservedTransport"]
@@ -128,7 +128,7 @@ class QStashClient:
128
128
  self._propagate = propagate
129
129
  self._closed = False
130
130
  self._http = HttpClient(
131
- obs, timeout_seconds=timeout_seconds, retries=0, transport=transport
131
+ obs, timeout_seconds=timeout_seconds, retries=0, log_failures=False, transport=transport
132
132
  )
133
133
 
134
134
  async def publish_json(
clients_021/http.py DELETED
@@ -1,131 +0,0 @@
1
- """Outbound HTTP: one httpx client per dependency, mandatory timeouts and bounded retries.
2
-
3
- from clients_021.http import HttpClient
4
-
5
- http = HttpClient(obs, timeout_seconds=10)
6
- response = await http.request("GET", url, dependency="identity-provider")
7
-
8
- Every call goes through 021-python-observability's transport: `http.client.request.*`
9
- under the `dependency` name, a CLIENT span, and `traceparent` / `x-request-id` sent along.
10
- Methods in IDEMPOTENT_METHODS are retried on transport errors and 502/503/504 with
11
- backoff; POST and PATCH are sent once (send an Idempotency-Key and retry in the caller if
12
- the other side supports it). A transport failure after the last attempt raises
13
- DependencyUnavailable; any HTTP status is returned to the caller to judge.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import asyncio
19
- import json as jsonlib
20
- import math
21
- from collections.abc import Mapping
22
- from dataclasses import dataclass
23
- from typing import Any, Literal
24
-
25
- import httpx
26
- from observability_021 import Observability
27
-
28
- from clients_021.errors import DependencyUnavailable
29
-
30
- HttpMethod = Literal["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]
31
- # Safe to send twice (RFC 9110, section 9.2.2): the only methods retried.
32
- IDEMPOTENT_METHODS: frozenset[HttpMethod] = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
33
- RETRY_STATUS = frozenset({502, 503, 504})
34
-
35
-
36
- @dataclass(frozen=True)
37
- class HttpResponse:
38
- status_code: int
39
- headers: Mapping[str, str]
40
- body: bytes
41
-
42
- def json(self) -> Any:
43
- return jsonlib.loads(self.body)
44
-
45
-
46
- class HttpClient:
47
- def __init__(
48
- self,
49
- obs: Observability,
50
- *,
51
- timeout_seconds: float,
52
- retries: int = 2,
53
- backoff_seconds: float = 0.2,
54
- transport: httpx.AsyncBaseTransport | None = None,
55
- ) -> None:
56
- if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
57
- raise ValueError("timeout_seconds must be finite and greater than 0")
58
- if type(retries) is not int or retries < 0:
59
- raise ValueError("retries must be a non-negative integer")
60
- if not math.isfinite(backoff_seconds) or backoff_seconds < 0:
61
- raise ValueError("backoff_seconds must be finite and non-negative")
62
- self._obs = obs
63
- self._timeout = httpx.Timeout(timeout_seconds, connect=min(timeout_seconds, 3.0))
64
- self._retries = retries
65
- self._backoff = backoff_seconds
66
- self._transport = transport
67
- self._clients: dict[str, httpx.AsyncClient] = {}
68
-
69
- def _client(self, dependency: str) -> httpx.AsyncClient:
70
- # One client per dependency, so every call is measured under a fixed name.
71
- client = self._clients.get(dependency)
72
- if client is None:
73
- client = httpx.AsyncClient(
74
- timeout=self._timeout,
75
- follow_redirects=False,
76
- transport=self._obs.async_httpx_transport(
77
- dependency=dependency, transport=self._transport
78
- ),
79
- )
80
- self._clients[dependency] = client
81
- return client
82
-
83
- async def request(
84
- self,
85
- method: HttpMethod,
86
- url: str,
87
- *,
88
- dependency: str,
89
- headers: Mapping[str, str] | None = None,
90
- json: Any = None,
91
- form: Mapping[str, str] | None = None,
92
- content: bytes | None = None,
93
- ) -> HttpResponse:
94
- if sum(value is not None for value in (json, form, content)) > 1:
95
- raise ValueError("provide only one of json, form or content")
96
- attempts = 1 + (self._retries if method in IDEMPOTENT_METHODS else 0)
97
- for attempt in range(attempts):
98
- last = attempt == attempts - 1
99
- try:
100
- response = await self._client(dependency).request(
101
- method,
102
- url,
103
- headers=dict(headers or {}),
104
- json=json,
105
- data=dict(form) if form is not None else None,
106
- content=content,
107
- )
108
- except (
109
- httpx.TimeoutException,
110
- httpx.NetworkError,
111
- httpx.RemoteProtocolError,
112
- httpx.ProxyError,
113
- ) as error:
114
- if last:
115
- reason = (
116
- "timeout"
117
- if isinstance(error, httpx.TimeoutException)
118
- else "connection_error"
119
- )
120
- raise DependencyUnavailable(dependency, method, reason) from error
121
- else:
122
- if response.status_code not in RETRY_STATUS or last:
123
- return HttpResponse(
124
- response.status_code, dict(response.headers), response.content
125
- )
126
- await asyncio.sleep(self._backoff * 2**attempt)
127
- raise AssertionError("unreachable")
128
-
129
- async def aclose(self) -> None:
130
- await asyncio.gather(*(client.aclose() for client in self._clients.values()))
131
- self._clients.clear()