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.
- {021_python_clients-0.2.0.dist-info → 021_python_clients-0.3.0.dist-info}/METADATA +58 -15
- {021_python_clients-0.2.0.dist-info → 021_python_clients-0.3.0.dist-info}/RECORD +12 -9
- clients_021/__init__.py +1 -1
- clients_021/email/resend.py +5 -1
- clients_021/http/__init__.py +11 -0
- clients_021/http/_observe.py +175 -0
- clients_021/http/httpx.py +280 -0
- clients_021/http/httpx2.py +76 -0
- clients_021/httpx2.py +3 -91
- clients_021/queue/qstash.py +1 -1
- clients_021/http.py +0 -131
- {021_python_clients-0.2.0.dist-info → 021_python_clients-0.3.0.dist-info}/WHEEL +0 -0
- {021_python_clients-0.2.0.dist-info → 021_python_clients-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: 021-python-clients
|
|
3
|
-
Version: 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`:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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=
|
|
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/
|
|
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=
|
|
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=
|
|
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.
|
|
26
|
-
021_python_clients-0.
|
|
27
|
-
021_python_clients-0.
|
|
28
|
-
021_python_clients-0.
|
|
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
|
|
clients_021/email/resend.py
CHANGED
|
@@ -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,
|
|
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
|
-
"""
|
|
1
|
+
"""Compatibility import; prefer clients_021.http.httpx2 for new code."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
from clients_021.httpx2 import ObservedTransport
|
|
3
|
+
from clients_021.http.httpx2 import ObservedTransport
|
|
5
4
|
|
|
6
|
-
|
|
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"]
|
clients_021/queue/qstash.py
CHANGED
|
@@ -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()
|
|
File without changes
|
|
File without changes
|