headgate 0.2.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.
- headgate-0.2.0/.gitignore +14 -0
- headgate-0.2.0/CHANGELOG.md +190 -0
- headgate-0.2.0/LICENSE +21 -0
- headgate-0.2.0/PKG-INFO +373 -0
- headgate-0.2.0/README.md +334 -0
- headgate-0.2.0/docs/auth.md +99 -0
- headgate-0.2.0/docs/breaker.md +74 -0
- headgate-0.2.0/docs/caching.md +111 -0
- headgate-0.2.0/docs/configuration.md +179 -0
- headgate-0.2.0/docs/deadlines.md +54 -0
- headgate-0.2.0/docs/design.md +144 -0
- headgate-0.2.0/docs/errors.md +78 -0
- headgate-0.2.0/docs/mutation-testing.md +172 -0
- headgate-0.2.0/docs/observability.md +107 -0
- headgate-0.2.0/docs/pagination.md +74 -0
- headgate-0.2.0/docs/rate-budgets.md +84 -0
- headgate-0.2.0/docs/responses.md +133 -0
- headgate-0.2.0/docs/retries.md +147 -0
- headgate-0.2.0/docs/security.md +107 -0
- headgate-0.2.0/docs/streaming.md +81 -0
- headgate-0.2.0/docs/testing.md +136 -0
- headgate-0.2.0/examples/__init__.py +4 -0
- headgate-0.2.0/examples/boundary.py +19 -0
- headgate-0.2.0/examples/orders_client.py +163 -0
- headgate-0.2.0/examples/quickstart.py +93 -0
- headgate-0.2.0/examples/series_client.py +73 -0
- headgate-0.2.0/pyproject.toml +155 -0
- headgate-0.2.0/src/headgate/__init__.py +131 -0
- headgate-0.2.0/src/headgate/_attempts.py +50 -0
- headgate-0.2.0/src/headgate/_auth.py +504 -0
- headgate-0.2.0/src/headgate/_body.py +115 -0
- headgate-0.2.0/src/headgate/_client.py +927 -0
- headgate-0.2.0/src/headgate/_codec.py +24 -0
- headgate-0.2.0/src/headgate/_core/__init__.py +88 -0
- headgate-0.2.0/src/headgate/_core/_context.py +228 -0
- headgate-0.2.0/src/headgate/_core/_events.py +248 -0
- headgate-0.2.0/src/headgate/_core/_origin.py +39 -0
- headgate-0.2.0/src/headgate/_core/_retry_after.py +76 -0
- headgate-0.2.0/src/headgate/_core/_seams.py +50 -0
- headgate-0.2.0/src/headgate/_core/_switch.py +40 -0
- headgate-0.2.0/src/headgate/_core/_time.py +102 -0
- headgate-0.2.0/src/headgate/_curl.py +287 -0
- headgate-0.2.0/src/headgate/_deprecation.py +101 -0
- headgate-0.2.0/src/headgate/_failures.py +439 -0
- headgate-0.2.0/src/headgate/_middleware.py +90 -0
- headgate-0.2.0/src/headgate/_options.py +358 -0
- headgate-0.2.0/src/headgate/_redirects.py +86 -0
- headgate-0.2.0/src/headgate/_request_body.py +81 -0
- headgate-0.2.0/src/headgate/_responses.py +290 -0
- headgate-0.2.0/src/headgate/_stack.py +185 -0
- headgate-0.2.0/src/headgate/_url.py +258 -0
- headgate-0.2.0/src/headgate/_user_agent.py +30 -0
- headgate-0.2.0/src/headgate/_view.py +105 -0
- headgate-0.2.0/src/headgate/breaker/__init__.py +24 -0
- headgate-0.2.0/src/headgate/breaker/_breaker.py +302 -0
- headgate-0.2.0/src/headgate/breaker/_policy.py +37 -0
- headgate-0.2.0/src/headgate/breaker/_registry.py +168 -0
- headgate-0.2.0/src/headgate/breaker/_scope.py +43 -0
- headgate-0.2.0/src/headgate/cache/__init__.py +35 -0
- headgate-0.2.0/src/headgate/cache/_conditional.py +89 -0
- headgate-0.2.0/src/headgate/cache/_credentials.py +143 -0
- headgate-0.2.0/src/headgate/cache/_policy.py +131 -0
- headgate-0.2.0/src/headgate/cache/_transport.py +568 -0
- headgate-0.2.0/src/headgate/curl.py +8 -0
- headgate-0.2.0/src/headgate/errors.py +329 -0
- headgate-0.2.0/src/headgate/events.py +45 -0
- headgate-0.2.0/src/headgate/health.py +73 -0
- headgate-0.2.0/src/headgate/msgspec.py +125 -0
- headgate-0.2.0/src/headgate/otel.py +236 -0
- headgate-0.2.0/src/headgate/pagination/__init__.py +25 -0
- headgate-0.2.0/src/headgate/pagination/_link.py +141 -0
- headgate-0.2.0/src/headgate/pagination/_pager.py +197 -0
- headgate-0.2.0/src/headgate/pagination/_paginated.py +82 -0
- headgate-0.2.0/src/headgate/policy.py +43 -0
- headgate-0.2.0/src/headgate/py.typed +0 -0
- headgate-0.2.0/src/headgate/pydantic.py +131 -0
- headgate-0.2.0/src/headgate/ratelimit/__init__.py +23 -0
- headgate-0.2.0/src/headgate/ratelimit/_budget.py +275 -0
- headgate-0.2.0/src/headgate/ratelimit/_budgets.py +62 -0
- headgate-0.2.0/src/headgate/ratelimit/_feedback.py +133 -0
- headgate-0.2.0/src/headgate/ratelimit/_fields.py +187 -0
- headgate-0.2.0/src/headgate/ratelimit/_inflight.py +92 -0
- headgate-0.2.0/src/headgate/ratelimit/_rate.py +103 -0
- headgate-0.2.0/src/headgate/ratelimit/_transport.py +106 -0
- headgate-0.2.0/src/headgate/retry/__init__.py +40 -0
- headgate-0.2.0/src/headgate/retry/_budget.py +73 -0
- headgate-0.2.0/src/headgate/retry/_classify.py +135 -0
- headgate-0.2.0/src/headgate/retry/_policy.py +254 -0
- headgate-0.2.0/src/headgate/retry/_tracking.py +121 -0
- headgate-0.2.0/src/headgate/retry/_transport.py +448 -0
- headgate-0.2.0/src/headgate/security/__init__.py +36 -0
- headgate-0.2.0/src/headgate/security/_addresses.py +120 -0
- headgate-0.2.0/src/headgate/security/_credentials.py +124 -0
- headgate-0.2.0/src/headgate/security/_downgrade.py +42 -0
- headgate-0.2.0/src/headgate/security/_transport.py +119 -0
- headgate-0.2.0/src/headgate/streaming/__init__.py +12 -0
- headgate-0.2.0/src/headgate/streaming/_stream.py +164 -0
- headgate-0.2.0/src/headgate/testing/__init__.py +58 -0
- headgate-0.2.0/src/headgate/testing/_breakers.py +33 -0
- headgate-0.2.0/src/headgate/testing/_recording.py +120 -0
- headgate-0.2.0/src/headgate/testing/_time.py +84 -0
- headgate-0.2.0/src/headgate/testing/_transport.py +115 -0
- headgate-0.2.0/src/headgate/testing/faults.py +275 -0
- headgate-0.2.0/tests/__init__.py +4 -0
- headgate-0.2.0/tests/auth/__init__.py +4 -0
- headgate-0.2.0/tests/auth/test_cached_token_auth.py +619 -0
- headgate-0.2.0/tests/auth/test_credential_stampers.py +238 -0
- headgate-0.2.0/tests/auth/test_flows_are_uniform.py +55 -0
- headgate-0.2.0/tests/auth/test_header_and_query_auth.py +91 -0
- headgate-0.2.0/tests/auth/test_mint_failures.py +440 -0
- headgate-0.2.0/tests/breaker/__init__.py +4 -0
- headgate-0.2.0/tests/breaker/test_breaker.py +420 -0
- headgate-0.2.0/tests/breaker/test_breaker_charging.py +244 -0
- headgate-0.2.0/tests/breaker/test_breaker_integration.py +398 -0
- headgate-0.2.0/tests/breaker/test_breaker_keying.py +152 -0
- headgate-0.2.0/tests/breaker/test_breaker_state_machine.py +151 -0
- headgate-0.2.0/tests/breaker/test_registry.py +173 -0
- headgate-0.2.0/tests/cache/__init__.py +4 -0
- headgate-0.2.0/tests/cache/_helpers.py +191 -0
- headgate-0.2.0/tests/cache/test_conditional.py +140 -0
- headgate-0.2.0/tests/cache/test_credentials.py +406 -0
- headgate-0.2.0/tests/cache/test_events.py +149 -0
- headgate-0.2.0/tests/cache/test_extension_passthrough.py +121 -0
- headgate-0.2.0/tests/cache/test_fallback.py +505 -0
- headgate-0.2.0/tests/cache/test_filters.py +56 -0
- headgate-0.2.0/tests/cache/test_held_bodies.py +255 -0
- headgate-0.2.0/tests/cache/test_integration.py +227 -0
- headgate-0.2.0/tests/cache/test_shared_downloads.py +495 -0
- headgate-0.2.0/tests/client/__init__.py +4 -0
- headgate-0.2.0/tests/client/test_call_options.py +181 -0
- headgate-0.2.0/tests/client/test_client.py +822 -0
- headgate-0.2.0/tests/client/test_deadline.py +97 -0
- headgate-0.2.0/tests/client/test_error_details.py +569 -0
- headgate-0.2.0/tests/client/test_error_messages.py +305 -0
- headgate-0.2.0/tests/client/test_events.py +183 -0
- headgate-0.2.0/tests/client/test_failure_policy.py +186 -0
- headgate-0.2.0/tests/client/test_invalid_url.py +52 -0
- headgate-0.2.0/tests/client/test_middleware.py +172 -0
- headgate-0.2.0/tests/client/test_options.py +451 -0
- headgate-0.2.0/tests/client/test_policy.py +182 -0
- headgate-0.2.0/tests/client/test_redirect_identity.py +71 -0
- headgate-0.2.0/tests/client/test_response_size_cap.py +324 -0
- headgate-0.2.0/tests/client/test_retry_opt_out.py +110 -0
- headgate-0.2.0/tests/client/test_verbs.py +209 -0
- headgate-0.2.0/tests/conftest.py +70 -0
- headgate-0.2.0/tests/core/__init__.py +4 -0
- headgate-0.2.0/tests/core/test_context.py +118 -0
- headgate-0.2.0/tests/core/test_events.py +45 -0
- headgate-0.2.0/tests/core/test_origin.py +38 -0
- headgate-0.2.0/tests/core/test_retry_after.py +70 -0
- headgate-0.2.0/tests/decoding/__init__.py +4 -0
- headgate-0.2.0/tests/decoding/test_errors.py +126 -0
- headgate-0.2.0/tests/decoding/test_event_stream.py +66 -0
- headgate-0.2.0/tests/decoding/test_msgspec.py +250 -0
- headgate-0.2.0/tests/decoding/test_pydantic.py +263 -0
- headgate-0.2.0/tests/decoding/test_responses.py +251 -0
- headgate-0.2.0/tests/errors/__init__.py +4 -0
- headgate-0.2.0/tests/errors/__snapshots__/test_error_messages_snapshot.ambr +467 -0
- headgate-0.2.0/tests/errors/test_error_messages_snapshot.py +399 -0
- headgate-0.2.0/tests/examples/__init__.py +4 -0
- headgate-0.2.0/tests/examples/test_docs.py +146 -0
- headgate-0.2.0/tests/examples/test_orders_client.py +112 -0
- headgate-0.2.0/tests/examples/test_quickstart.py +118 -0
- headgate-0.2.0/tests/examples/test_series_client.py +40 -0
- headgate-0.2.0/tests/integration/__init__.py +4 -0
- headgate-0.2.0/tests/integration/_support.py +107 -0
- headgate-0.2.0/tests/integration/test_breaker_gate.py +71 -0
- headgate-0.2.0/tests/integration/test_cache_and_auths.py +108 -0
- headgate-0.2.0/tests/integration/test_deadline.py +91 -0
- headgate-0.2.0/tests/integration/test_destinations.py +99 -0
- headgate-0.2.0/tests/integration/test_events.py +85 -0
- headgate-0.2.0/tests/integration/test_hedging.py +120 -0
- headgate-0.2.0/tests/integration/test_pagination_credentials.py +59 -0
- headgate-0.2.0/tests/integration/test_replayable_body.py +140 -0
- headgate-0.2.0/tests/integration/test_retry_and_rate.py +60 -0
- headgate-0.2.0/tests/integration/test_views.py +94 -0
- headgate-0.2.0/tests/observe/__init__.py +4 -0
- headgate-0.2.0/tests/observe/__snapshots__/test_snapshots.ambr +703 -0
- headgate-0.2.0/tests/observe/_support.py +46 -0
- headgate-0.2.0/tests/observe/test_accept.py +129 -0
- headgate-0.2.0/tests/observe/test_deprecation.py +124 -0
- headgate-0.2.0/tests/observe/test_otel.py +416 -0
- headgate-0.2.0/tests/observe/test_otel_isolation.py +41 -0
- headgate-0.2.0/tests/observe/test_snapshots.py +183 -0
- headgate-0.2.0/tests/observe/test_with_options.py +229 -0
- headgate-0.2.0/tests/package/__init__.py +4 -0
- headgate-0.2.0/tests/package/__snapshots__/test_api_surface.ambr +717 -0
- headgate-0.2.0/tests/package/test_api_surface.py +150 -0
- headgate-0.2.0/tests/package/test_boundaries.py +117 -0
- headgate-0.2.0/tests/package/test_contracts.py +33 -0
- headgate-0.2.0/tests/package/test_import_isolation.py +54 -0
- headgate-0.2.0/tests/package/test_metadata.py +46 -0
- headgate-0.2.0/tests/pagination/__init__.py +4 -0
- headgate-0.2.0/tests/pagination/test_link_header.py +133 -0
- headgate-0.2.0/tests/pagination/test_paginate.py +359 -0
- headgate-0.2.0/tests/performance/__init__.py +4 -0
- headgate-0.2.0/tests/performance/test_memory.py +182 -0
- headgate-0.2.0/tests/properties/__init__.py +4 -0
- headgate-0.2.0/tests/properties/test_policy_properties.py +87 -0
- headgate-0.2.0/tests/properties/test_url_properties.py +79 -0
- headgate-0.2.0/tests/ratelimit/__init__.py +4 -0
- headgate-0.2.0/tests/ratelimit/test_budget.py +536 -0
- headgate-0.2.0/tests/ratelimit/test_client_feedback.py +122 -0
- headgate-0.2.0/tests/ratelimit/test_feedback.py +230 -0
- headgate-0.2.0/tests/ratelimit/test_in_flight.py +267 -0
- headgate-0.2.0/tests/ratelimit/test_rate.py +75 -0
- headgate-0.2.0/tests/ratelimit/test_rate_limit_transport.py +222 -0
- headgate-0.2.0/tests/retry/__init__.py +4 -0
- headgate-0.2.0/tests/retry/test_budget_and_classify.py +172 -0
- headgate-0.2.0/tests/retry/test_client_retries.py +205 -0
- headgate-0.2.0/tests/retry/test_faults.py +322 -0
- headgate-0.2.0/tests/retry/test_parking_and_hedging.py +415 -0
- headgate-0.2.0/tests/retry/test_policy.py +145 -0
- headgate-0.2.0/tests/retry/test_properties.py +237 -0
- headgate-0.2.0/tests/retry/test_tracking.py +294 -0
- headgate-0.2.0/tests/retry/test_transport.py +845 -0
- headgate-0.2.0/tests/security/__init__.py +4 -0
- headgate-0.2.0/tests/security/_servers.py +154 -0
- headgate-0.2.0/tests/security/_web.py +51 -0
- headgate-0.2.0/tests/security/certs/ca.pem +10 -0
- headgate-0.2.0/tests/security/certs/server.pem +17 -0
- headgate-0.2.0/tests/security/test_addresses.py +132 -0
- headgate-0.2.0/tests/security/test_credential_origins.py +287 -0
- headgate-0.2.0/tests/security/test_origin_properties.py +122 -0
- headgate-0.2.0/tests/security/test_pinned_transport.py +164 -0
- headgate-0.2.0/tests/security/test_redirect_hardening.py +155 -0
- headgate-0.2.0/tests/security/test_ssrf_guard.py +207 -0
- headgate-0.2.0/tests/streaming/__init__.py +4 -0
- headgate-0.2.0/tests/streaming/test_request_bodies.py +169 -0
- headgate-0.2.0/tests/streaming/test_stream.py +412 -0
- headgate-0.2.0/tests/support/__init__.py +4 -0
- headgate-0.2.0/tests/support/breakers.py +20 -0
- headgate-0.2.0/tests/support/budgets.py +53 -0
- headgate-0.2.0/tests/support/clients.py +12 -0
- headgate-0.2.0/tests/support/events.py +27 -0
- headgate-0.2.0/tests/support/responses.py +31 -0
- headgate-0.2.0/tests/support/tokens.py +24 -0
- headgate-0.2.0/tests/support/wire.py +29 -0
- headgate-0.2.0/tests/testing/__init__.py +4 -0
- headgate-0.2.0/tests/testing/test_breakers.py +96 -0
- headgate-0.2.0/tests/testing/test_cassettes.py +103 -0
- headgate-0.2.0/tests/testing/test_fake_transport.py +114 -0
- headgate-0.2.0/tests/testing/test_recording.py +111 -0
- headgate-0.2.0/tests/testing/test_time.py +80 -0
- headgate-0.2.0/tests/transport/__init__.py +4 -0
- headgate-0.2.0/tests/transport/test_curl.py +484 -0
- headgate-0.2.0/tests/transport/test_health.py +143 -0
- headgate-0.2.0/tests/urls/__init__.py +4 -0
- headgate-0.2.0/tests/urls/test_empty_segment.py +34 -0
- headgate-0.2.0/tests/urls/test_path_escaping.py +234 -0
- headgate-0.2.0/tests/urls/test_route_template.py +24 -0
- headgate-0.2.0/tests/urls/test_sanitize_url.py +114 -0
- headgate-0.2.0/tests/urls/test_secret_query_params.py +89 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to headgate are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
|
+
and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Before 1.0, a minor release may
|
|
5
|
+
change the public API; such changes are listed under **Changed** and **Removed** with what to write instead.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-10-25
|
|
10
|
+
|
|
11
|
+
The first release published to PyPI. It reshapes the client around option objects and rebuilds every policy
|
|
12
|
+
layer. Code written against the 0.1.0 source needs the migrations below; there are no compatibility aliases.
|
|
13
|
+
|
|
14
|
+
### Migrating from 0.1.0
|
|
15
|
+
|
|
16
|
+
| 0.1.0 | 0.2.0 |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `AsyncBaseClient(..., max_retries=N)` | `policy=TransportPolicy(retry=RetryPolicy(attempts=N))`; both count sends, the first included |
|
|
19
|
+
| `circuit_breaker=False` | `policy=TransportPolicy(breaker=None)` |
|
|
20
|
+
| `rate_limits=(...)` | `policy=TransportPolicy(rate_limits=(...))` |
|
|
21
|
+
| `cache=HttpCache(...)` | `policy=TransportPolicy(cache=HttpCache(...))` |
|
|
22
|
+
| `default_headers={...}` | `http=HttpOptions(headers={...})` |
|
|
23
|
+
| `timeout=30.0` | `http=HttpOptions(timeout=30.0)` (the read timeout) or `timeout=Timeouts(...)` |
|
|
24
|
+
| `transport=...` | `http=HttpOptions(transport=...)` |
|
|
25
|
+
| `max_response_bytes=...` | `http=HttpOptions(max_response_bytes=...)` |
|
|
26
|
+
| `follow_redirects=...` | `http=HttpOptions(follow_redirects=...)` |
|
|
27
|
+
| `refusal=check` | `http=HttpOptions(refusal_check=check)` |
|
|
28
|
+
| `Refused` | `RefusedError` |
|
|
29
|
+
| `Refusal` (the check's type) | `RefusalCheck` |
|
|
30
|
+
| `client.stream_get(url)` (an async iterator of chunks) | `async with client.stream("GET", url) as stream: async for chunk in stream.aiter_bytes():` |
|
|
31
|
+
| `StaticHeaderAuth.header_names` | `credential_headers` (every auth now has it, and `credential_params`) |
|
|
32
|
+
| `CachedTokenAuth.reject_credential_rotation(..., env_var=...)` | `setting=...` |
|
|
33
|
+
| `HttpCache(policy, None)` to disable the cache | leave `TransportPolicy.cache` as `None`; `storage` is required |
|
|
34
|
+
| the private `_host_breakers` registry | `headgate.breaker.DEFAULT_REGISTRY`, a `BreakerRegistry` |
|
|
35
|
+
| `health.breaker_states()` keyed by host | keyed by origin, `scheme://host[:port]`; takes an optional `registry` |
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **Option objects**: `TransportPolicy` (retry, deadline, breaker, rate rules, cache, idempotency keys, per-route
|
|
40
|
+
overrides) and `HttpOptions` (transport, headers, `Timeouts`, redirects and `max_redirects`, the body cap, the refusal
|
|
41
|
+
check, `trusted_origins`, the SSRF guard and its `resolver`, `ssl_context`, `limits`, `proxy`, `user_agent`,
|
|
42
|
+
`cookies`, `request_id_headers`, `observers`, `middleware`). Frozen and validated on construction; an upstream `httpx`
|
|
43
|
+
object raises `TypeError`.
|
|
44
|
+
- **Retries** (`headgate.retry`): `RetryPolicy`, `Backoff`, `RetryBudget`, `Hedge`, `NO_RETRY`. Send tracking decides
|
|
45
|
+
whether a write may be re-sent; 425 and HTTP/2 `REFUSED_STREAM` are retried as unprocessed; `x-should-retry` is
|
|
46
|
+
honoured; a ratio retry budget per client; opt-in hedging; attempt timeouts fitted to the deadline; an attempt history
|
|
47
|
+
in the error's notes. `IdempotencyKeys` and `idempotency_key=` make a POST safe to retry. `Route` sets a retry policy
|
|
48
|
+
or deadline per path.
|
|
49
|
+
- **A whole-call deadline**: `TransportPolicy(deadline=60.0)` by default, `deadline=` per call.
|
|
50
|
+
- **Rate budgets** (`headgate.ratelimit`): `RateBudget`, shareable between clients, event loops and threads, with GCRA
|
|
51
|
+
pacing, `max_in_flight`, server feedback from `Retry-After`, `RateLimit` / `RateLimit-Policy` and
|
|
52
|
+
`X-RateLimit-Remaining` / `-Reset`, and `Adaptive` throttling, on by default.
|
|
53
|
+
- **Breakers** (`headgate.breaker`): `BreakerPolicy` and `BreakerRegistry`, keyed by origin and thresholds, thread-safe
|
|
54
|
+
and bounded. `CircuitOpenError`, a `TransportError`, with `retry_after`.
|
|
55
|
+
- **Security** (`headgate.security`): credentials pinned to `base_url`'s origin and `trusted_origins` on every hop,
|
|
56
|
+
https-to-http redirects refused, and an opt-in SSRF guard (`allow_private_hosts=False`) that pins connections to the
|
|
57
|
+
vetted address. `ForbiddenDestinationError`.
|
|
58
|
+
- **Cache**: `Partition` (per credential by default, or `SHARED`), `conditional()` reads with `Validator`, `Fresh` and
|
|
59
|
+
`NotModified`.
|
|
60
|
+
- **Streaming and bodies**: `stream()` for any method, returning a `Stream` with `aiter_bytes`, `aiter_lines` and
|
|
61
|
+
incremental `aiter_events` (server-sent events with `Last-Event-ID` resume); `content=` (bytes, a one-shot stream, or
|
|
62
|
+
a replayable factory) and `files=` (multipart).
|
|
63
|
+
- **Pagination**: `paginate()` with `Cursor`, `Offset`, `LinkHeader` and `NextUrl`, or a `Pager` of your own; lazy, and
|
|
64
|
+
loop-safe.
|
|
65
|
+
- **Per-call ergonomics**: `with_options()` views; `retry=` takes a `RetryPolicy`; `Expect(accept=...)` checks the
|
|
66
|
+
`Content-Type`; `Expect(headers=...)`.
|
|
67
|
+
- **Errors**: every `ClientError` carries `method`, `url`, `request_id`, `attempts`, `retryable` and `retry_after`;
|
|
68
|
+
`UpstreamStatusError.problem` parses RFC 9457 problem details (`Problem`); `ResponseParseError` has `status_code` and
|
|
69
|
+
`content_type`. Every error pickles.
|
|
70
|
+
- **Observability**: typed events in `headgate.events` delivered to `Observer`s; `headgate.otel.OpenTelemetryObserver`
|
|
71
|
+
(the `otel` extra) with one CLIENT span and duration per call and `traceparent` on every attempt; `Deprecated`
|
|
72
|
+
events for `Deprecation` and `Sunset` headers; a `User-Agent` naming headgate; `health.observer_failures()`.
|
|
73
|
+
- **Middleware**: per-attempt `Middleware` inside the retry layer, for request signing.
|
|
74
|
+
- **Testing kit** (`headgate.testing`): `FakeTransport`, `FaultyTransport` and its fault steps, `TestClock`,
|
|
75
|
+
`instant_retries()`, `isolated_breakers()` and `CassetteFilter` for vcrpy.
|
|
76
|
+
- `Method` and `RequestOptions` are public; `RequestOptions` gains `content`, `files`, `deadline` and
|
|
77
|
+
`idempotency_key`, and its `timeout` takes `Timeouts`.
|
|
78
|
+
- `CachedTokenAuth.seed()`, `token_headers` and `credential_identity()`; `Minted` for mint flows.
|
|
79
|
+
- The `cache` extra (`pip install 'headgate[cache]'`), which brings `hishel[async]` and so the `anysqlite` that
|
|
80
|
+
hishel's `AsyncSqliteStorage` needs.
|
|
81
|
+
- Documentation: a guide per area in `docs/`, a design document, and tested examples.
|
|
82
|
+
|
|
83
|
+
- `headgate.MAX_MINT_RESPONSE_BYTES`, the cap on the response to each hop of a token mint.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- **Only safe re-sends.** 0.1.0 retried every method, POST included, after any failure. A POST or PATCH is now re-sent
|
|
88
|
+
only when nothing of it was written, the server proved it unprocessed, or it carries an idempotency key.
|
|
89
|
+
- **Retried statuses** are 408, 425, 429, 500, 502, 503 and 504; 501 and other 5xx are no longer retried, and local
|
|
90
|
+
errors (an unencodable header, an unsupported scheme, an invalid URL) are never retried.
|
|
91
|
+
- **`Retry-After`** is waited exactly up to `RetryPolicy.max_retry_after` (30 s) instead of being clamped to 5 s; a longer
|
|
92
|
+
one raises at once with `retry_after` set.
|
|
93
|
+
- **Retry budget.** Retries are capped per client at 20% of first attempts plus 10 a second.
|
|
94
|
+
- **A 60-second deadline** now bounds every call by default, retries and waits included; pass `deadline=None` (per call,
|
|
95
|
+
per route or in the policy) for none.
|
|
96
|
+
- **The breaker** is keyed by origin (scheme, host and port) instead of host, keeps separate breakers for clients with
|
|
97
|
+
different thresholds, no longer counts a 429 or a failed token mint, and stops a retry sequence as soon as it opens.
|
|
98
|
+
- **Rate limiting** reacts to the server: a 429/503 or exhausted rate-limit headers pause the shared budget, and
|
|
99
|
+
adaptive throttling lowers the rate on pushback. Pass `RateBudget(rate, adaptive=None)` for fixed pacing.
|
|
100
|
+
- **Cookies** are neither stored nor sent back unless `HttpOptions(cookies=True)`.
|
|
101
|
+
- **`HttpCache`** is keyword-only, its `storage` is required, and a client whose auth does not declare its credentials
|
|
102
|
+
cannot use a credential-partitioned cache (`ClientConfigurationError`).
|
|
103
|
+
- **Codecs** take a type expression (`TypeForm`), so `decoder(list[A] | A)` is typed exactly.
|
|
104
|
+
- Error messages name the request line and a sanitized, bounded URL, and fold an RFC 9457 problem's title and detail in
|
|
105
|
+
place of the raw body.
|
|
106
|
+
- The example client is `examples/series_client.py`; the test suite uses `httpx2-pytest`.
|
|
107
|
+
|
|
108
|
+
### Removed
|
|
109
|
+
|
|
110
|
+
- The `aiolimiter` dependency: pacing is headgate's own GCRA budget.
|
|
111
|
+
- `AsyncBaseClient`'s individual keyword options, `stream_get()`, `Refused`, `Refusal` and `header_names` (see the
|
|
112
|
+
migration table).
|
|
113
|
+
- The repository's `headgate_pytest_alias.py` plugin and the golden retry table, replaced by behaviour and property
|
|
114
|
+
tests of the new retry policy.
|
|
115
|
+
|
|
116
|
+
### Fixed
|
|
117
|
+
|
|
118
|
+
- The client and its transport no longer read proxy variables or `.netrc` from the environment (`trust_env=False`), as
|
|
119
|
+
the no-environment contract always said.
|
|
120
|
+
- curl transport: closing a response early no longer downloads the rest of the body; cached gzip bodies replay;
|
|
121
|
+
mid-body curl errors map onto httpx2 exceptions; `Timeout(None)` no longer crashes; curl error codes map to the right
|
|
122
|
+
httpx2 exceptions.
|
|
123
|
+
- Cache: an over-cap body no longer leaks its pooled connection; the network URL is no longer reordered; cancellation
|
|
124
|
+
no longer leaks responses; concurrent GETs share a download only when every header matches, so a `Range`, `Accept`
|
|
125
|
+
or credential difference no longer returns another request's response; a shared failure counts once toward the
|
|
126
|
+
breaker, not once per waiting request.
|
|
127
|
+
- Auth: a token mint's hops are marked, so their failures are no longer charged to the API host's breaker; a mint flow
|
|
128
|
+
that ends without a token raises a clear error; mint failures map onto the `ClientError` tree, naming the token
|
|
129
|
+
endpoint.
|
|
130
|
+
- `ClientError.retryable` is the retry policy's verdict on the outcome: a surviving 501 or 505, a 503 marked
|
|
131
|
+
`x-should-retry: false`, a 503 on a POST without an idempotency key, and a body-read failure on such a POST are no
|
|
132
|
+
longer reported retryable.
|
|
133
|
+
- Auth: a mint flow is handed each hop's response read (bounded to 1 MiB), so `.json()` and `.content` work instead of
|
|
134
|
+
raising `httpx2.ResponseNotRead`; an oversized or dropped token response, an `httpx2.StreamError` and an
|
|
135
|
+
`httpx2.CookieConflict` map onto the `ClientError` tree. Every hop gets the call's timeouts, cut to its deadline,
|
|
136
|
+
unless it sets its own.
|
|
137
|
+
- A hedged request no longer sends one more attempt than `RetryPolicy.attempts` when `Hedge.max_extra` allows more
|
|
138
|
+
hedges than the attempts left.
|
|
139
|
+
- A redirect chain past `max_redirects` is not retryable and not charged to the origin's breaker: the same request
|
|
140
|
+
would loop the same way, and the origin answered every hop.
|
|
141
|
+
- A surviving 5xx the retry policy does not re-send (a 501, a 505) is worded `upstream 501 ...`; `transient` is kept for
|
|
142
|
+
the statuses the policy retries.
|
|
143
|
+
- A non-ASCII `Content-Length` no longer crashes; error snippets no longer decode a whole 32 MiB body.
|
|
144
|
+
- Backoff no longer overflows past 1023 retries or produces a NaN wait; `Rate` refuses NaN and infinite values.
|
|
145
|
+
- `sanitize_url` no longer rewrites untouched parts of a URL and never raises; `event_stream` skips data-less blocks;
|
|
146
|
+
`drop_none` walks tuples.
|
|
147
|
+
- A hand-written reader's `ValueError`, `LookupError` or `TypeError` on a 2xx is a `ResponseParseError`; a
|
|
148
|
+
`refusal_check` that raises `RefusedError` is treated like one that returns it.
|
|
149
|
+
|
|
150
|
+
### Security
|
|
151
|
+
|
|
152
|
+
- A middleware that declares the credentials it stamps (`credential_headers`, `credential_params`) has them withheld
|
|
153
|
+
from any untrusted origin: the credential guard runs again below the last middleware, so a signer no longer signs a
|
|
154
|
+
cross-origin redirect hop.
|
|
155
|
+
- Credentials stay on trusted origins on every hop: an absolute URL to another origin, a redirect, or a cursor URL no
|
|
156
|
+
longer carries `Authorization`, an auth's declared headers or a `QueryParamAuth` key. 0.1.0 withheld only a static
|
|
157
|
+
header, and only on a cross-origin redirect.
|
|
158
|
+
- The cache is partitioned by credential and stores no credential, cookie or `Set-Cookie`; 0.1.0 could serve a
|
|
159
|
+
response cached under one credential to a request made with another, and stored the request's credential headers.
|
|
160
|
+
- A redirect from `https` to `http` is refused.
|
|
161
|
+
- A header value the HTTP library refuses to encode, usually the credential, no longer appears in the error or its
|
|
162
|
+
traceback; parse errors no longer quote body values; URL userinfo is redacted whole, and the list of secret query
|
|
163
|
+
parameters covers OAuth, session and signed-URL parameters; a cache backend's message, which can carry its DSN, is
|
|
164
|
+
never repeated.
|
|
165
|
+
- A URL an error names has its control characters percent-encoded, so a URL taken from a response (a cursor, a
|
|
166
|
+
redirect) cannot forge a line in a log that records the error.
|
|
167
|
+
|
|
168
|
+
## [0.1.0] - 2026-10-24
|
|
169
|
+
|
|
170
|
+
The initial version, in the repository only (it was not published to PyPI): an async HTTP client base on httpx2 for
|
|
171
|
+
Python 3.14.
|
|
172
|
+
|
|
173
|
+
### Added
|
|
174
|
+
|
|
175
|
+
- `AsyncBaseClient` with one method per verb, each taking the call's declared responses as `expect=`: a reader or an
|
|
176
|
+
`Expect` with exact non-2xx answers, anything else raising a `ClientError` subtype.
|
|
177
|
+
- Retries with exponential backoff and `Retry-After`, per call opt-out with `retry=False`.
|
|
178
|
+
- A token-bucket rate limit per client (`Rate`), with per-path `RateRule`s.
|
|
179
|
+
- A per-host circuit breaker spanning the send and the body read.
|
|
180
|
+
- A response body cap counted on decompressed bytes.
|
|
181
|
+
- `StaticHeaderAuth`, `QueryParamAuth` and `CachedTokenAuth`.
|
|
182
|
+
- An opt-in `HttpCache` (hishel) with `PathFragmentFilter`, `MethodFilter` and `StatusCodeFilter`, degrading to the
|
|
183
|
+
network when its storage fails.
|
|
184
|
+
- t-string URL paths that escape every interpolated value as one segment.
|
|
185
|
+
- The `headgate.msgspec` and `headgate.pydantic` codec flavors, the optional `AsyncCurlTransport` (`headgate[curl]`),
|
|
186
|
+
`headgate.health` snapshots and `headgate.policy.policy_disabled()`.
|
|
187
|
+
|
|
188
|
+
[Unreleased]: https://github.com/kelsayed17/headgate/compare/v0.2.0...HEAD
|
|
189
|
+
[0.2.0]: https://github.com/kelsayed17/headgate/compare/f319ad8...v0.2.0
|
|
190
|
+
[0.1.0]: https://github.com/kelsayed17/headgate/commit/f319ad8
|
headgate-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Khaled Elsayed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
headgate-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: headgate
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: The async API-client base on httpx2: safe retries, rate budgets, per-origin breakers, credential-safe caching, typed events and declared responses.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kelsayed17/headgate
|
|
6
|
+
Project-URL: Documentation, https://github.com/kelsayed17/headgate/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/kelsayed17/headgate
|
|
8
|
+
Project-URL: Issues, https://github.com/kelsayed17/headgate/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/kelsayed17/headgate/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Security, https://github.com/kelsayed17/headgate/security/policy
|
|
11
|
+
Author-email: Khaled Elsayed <4119211+kelsayed17@users.noreply.github.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: api-client,async,asyncio,caching,circuit-breaker,http,http-client,httpx,httpx2,opentelemetry,rate-limiting,retry
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.14
|
|
26
|
+
Requires-Dist: hishel>=1.4.0
|
|
27
|
+
Requires-Dist: httpx2[http2]>=2.16.0
|
|
28
|
+
Requires-Dist: msgspec>=0.22.0
|
|
29
|
+
Requires-Dist: typing-extensions>=4.15; python_version < '3.15'
|
|
30
|
+
Provides-Extra: cache
|
|
31
|
+
Requires-Dist: hishel[async]>=1.4.0; extra == 'cache'
|
|
32
|
+
Provides-Extra: curl
|
|
33
|
+
Requires-Dist: curl-cffi>=0.16.3; extra == 'curl'
|
|
34
|
+
Provides-Extra: otel
|
|
35
|
+
Requires-Dist: opentelemetry-api>=1.45.1; extra == 'otel'
|
|
36
|
+
Provides-Extra: pydantic
|
|
37
|
+
Requires-Dist: pydantic>=2.14.1; extra == 'pydantic'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# headgate
|
|
41
|
+
|
|
42
|
+
**An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
|
|
43
|
+
`AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour:
|
|
44
|
+
retries that re-send only when it is safe, a shared rate budget that listens to the server, a per-origin circuit
|
|
45
|
+
breaker, whole-call deadlines, credentials that never leave your origin, an opt-in cache that never stores them, typed
|
|
46
|
+
events and OpenTelemetry, and errors that say exactly what happened. Each endpoint method stays one line.
|
|
47
|
+
|
|
48
|
+
A headgate controls how much water gets through a channel. This library does the same for requests, in one place,
|
|
49
|
+
so that no client has to get it right on its own.
|
|
50
|
+
|
|
51
|
+
## Requirements and install
|
|
52
|
+
|
|
53
|
+
Python 3.14 or newer (endpoint paths are t-strings) and `httpx2`, pydantic's fork of httpx. Upstream `httpx` is not
|
|
54
|
+
supported: passing one of its objects raises a `TypeError` naming the mistake.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install headgate # or: uv add headgate. Brings httpx2[http2], hishel and msgspec
|
|
58
|
+
pip install 'headgate[pydantic]' # + the pydantic codec flavor
|
|
59
|
+
pip install 'headgate[curl]' # + AsyncCurlTransport: a browser's TLS fingerprint, via curl_cffi
|
|
60
|
+
pip install 'headgate[otel]' # + OpenTelemetryObserver (opentelemetry-api only)
|
|
61
|
+
pip install 'headgate[cache]' # + what hishel's AsyncSqliteStorage needs (hishel[async]: anysqlite)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quickstart
|
|
65
|
+
|
|
66
|
+
A client is a subclass that fixes its configuration in `super().__init__()` and exposes one method per endpoint. A
|
|
67
|
+
path is a t-string, so every value is escaped as one segment; `expect=` declares what the endpoint answers.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from http import HTTPStatus
|
|
71
|
+
from typing import TYPE_CHECKING
|
|
72
|
+
|
|
73
|
+
import msgspec
|
|
74
|
+
|
|
75
|
+
from headgate import AsyncBaseClient, Cursor, Expect, HttpOptions, Rate, StaticHeaderAuth, nothing
|
|
76
|
+
from headgate.msgspec import decoder
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING:
|
|
79
|
+
import httpx2
|
|
80
|
+
|
|
81
|
+
from headgate import Paginated
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Series(msgspec.Struct, frozen=True):
|
|
85
|
+
"""A data series: its id, title and observations."""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
title: str
|
|
89
|
+
values: list[float]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Release(msgspec.Struct, frozen=True):
|
|
93
|
+
"""A scheduled data release."""
|
|
94
|
+
|
|
95
|
+
id: int
|
|
96
|
+
name: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ReleasePage(msgspec.Struct, frozen=True):
|
|
100
|
+
"""One page of releases, and the cursor of the next page (``None`` on the last)."""
|
|
101
|
+
|
|
102
|
+
releases: list[Release]
|
|
103
|
+
next_cursor: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def releases_on(page: ReleasePage) -> list[Release]:
|
|
107
|
+
"""Return the releases on *page*."""
|
|
108
|
+
return page.releases
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cursor_after(page: ReleasePage) -> str | None:
|
|
112
|
+
"""Return the cursor of the page after *page*."""
|
|
113
|
+
return page.next_cursor
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Readers and pagers are values: build them once, at import, where a bad declaration fails.
|
|
117
|
+
SERIES = decoder(Series)
|
|
118
|
+
SERIES_OR_NONE = Expect(SERIES).on(HTTPStatus.NOT_FOUND, nothing) # the API documents 404 as "no such series"
|
|
119
|
+
RELEASE_PAGES = Cursor(items=releases_on, next=cursor_after, param="cursor")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CatalogClient(AsyncBaseClient):
|
|
123
|
+
"""The catalog API: one method per endpoint, each one line."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, api_key: str, *, transport: httpx2.AsyncBaseTransport | None = None) -> None:
|
|
126
|
+
"""Authenticate with *api_key*; *transport* replaces the network, as a test does."""
|
|
127
|
+
super().__init__(
|
|
128
|
+
base_url="https://api.catalog.example/v1",
|
|
129
|
+
rate=Rate.per_second(10),
|
|
130
|
+
auth=StaticHeaderAuth({"Authorization": f"Bearer {api_key}"}),
|
|
131
|
+
http=HttpOptions(transport=transport),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def get_series(self, series_id: str) -> Series:
|
|
135
|
+
"""Return the series *series_id*; a 404, undeclared here, raises ``UpstreamStatusError``."""
|
|
136
|
+
return await self.get(t"series/{series_id}", expect=SERIES)
|
|
137
|
+
|
|
138
|
+
async def find_series(self, series_id: str) -> Series | None:
|
|
139
|
+
"""Return the series *series_id*, or ``None`` when the API answers 404."""
|
|
140
|
+
return await self.get(t"series/{series_id}", expect=SERIES_OR_NONE)
|
|
141
|
+
|
|
142
|
+
def releases(self) -> Paginated[ReleasePage, Release]:
|
|
143
|
+
"""Return every release, fetched a page at a time as the iteration reaches it."""
|
|
144
|
+
return self.paginate("releases", expect=decoder(ReleasePage), pages=RELEASE_PAGES)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Use it with `async with`, which closes the connection pool on the way out:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
async def main(api_key: str) -> tuple[float, Series | None, list[str]]:
|
|
151
|
+
"""Read one series, look up one that may not exist, and list every release."""
|
|
152
|
+
async with CatalogClient(api_key) as catalog:
|
|
153
|
+
gdp = await catalog.get_series("GDP")
|
|
154
|
+
retired = await catalog.find_series("RETIRED") # None: the API answered 404
|
|
155
|
+
names = [release.name async for release in catalog.releases()]
|
|
156
|
+
return gdp.values[-1], retired, names
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Nothing in those methods mentions retries, rate limits, timeouts or credentials, and nothing needs to:
|
|
160
|
+
`get_series("GDP")` is paced by the client's budget, bounded by a 60-second deadline, retried on a 503 or a dropped
|
|
161
|
+
connection, refused fast while the origin's breaker is open, and fails with an error that names the client, the
|
|
162
|
+
request line, the status, the request id and every attempt. [`examples/`](examples/) has this client, the same client
|
|
163
|
+
in both codec flavors, and a fuller one ([`orders_client.py`](examples/orders_client.py)): a minted OAuth token, an
|
|
164
|
+
idempotent POST, a declared business refusal, `Link`-header pages and a streamed export.
|
|
165
|
+
|
|
166
|
+
## What every client gets
|
|
167
|
+
|
|
168
|
+
| Capability | What it does by default | Guide |
|
|
169
|
+
| --- | --- | --- |
|
|
170
|
+
| Declared responses | A reader for any 2xx, `Expect(...).on(status, reader)` for documented non-2xx answers; anything else raises | [responses](docs/responses.md) |
|
|
171
|
+
| Retries | 3 sends with jittered backoff; idempotent methods after a network failure or a 408, 425, 429, 500, 502, 503, 504; a POST only when provably unprocessed or keyed | [retries](docs/retries.md) |
|
|
172
|
+
| Retry budget and hedging | Retries capped at 20% of first attempts (plus 10 a second) per client; opt-in hedged sends for slow idempotent calls | [retries](docs/retries.md#the-retry-budget) |
|
|
173
|
+
| `Retry-After` | Waited exactly up to 30 s, past that raised with `retry_after` set | [retries](docs/retries.md#retry-after) |
|
|
174
|
+
| Rate budgets | GCRA pacing, shareable across clients and threads, per-path lanes, an optional in-flight cap | [rate budgets](docs/rate-budgets.md) |
|
|
175
|
+
| Server feedback | 429/503 and `RateLimit` / `X-RateLimit-*` headers pause the whole budget; adaptive throttling (AIMD) | [rate budgets](docs/rate-budgets.md#server-feedback) |
|
|
176
|
+
| Circuit breaker | Per origin, opens after 5 consecutive origin failures, one probe after 60 s | [breaker](docs/breaker.md) |
|
|
177
|
+
| Deadlines and timeouts | 60 s for the whole call; each attempt's timeouts cut to what is left | [deadlines](docs/deadlines.md) |
|
|
178
|
+
| Credentials stay home | Auth headers and params go only to `base_url`'s origin and `trusted_origins`, on every hop | [security](docs/security.md) |
|
|
179
|
+
| Redirect hardening | No `https` to `http` downgrade, a hop limit, no re-sent one-shot body | [security](docs/security.md#redirects) |
|
|
180
|
+
| SSRF guard | Opt-in: refuse private, loopback and other non-global addresses, pinned at connect time | [security](docs/security.md#the-ssrf-guard) |
|
|
181
|
+
| Response cap | 32 MiB per buffered body, counted after decompression | [configuration](docs/configuration.md#httpoptions) |
|
|
182
|
+
| Caching | Opt-in hishel cache, partitioned by credential, never storing one; coalesced identical GETs; conditional reads | [caching](docs/caching.md) |
|
|
183
|
+
| Streaming and uploads | `stream()` for bodies and server-sent events with resume; raw, streamed and multipart request bodies | [streaming](docs/streaming.md) |
|
|
184
|
+
| Pagination | `paginate()` with `Cursor`, `Offset`, `LinkHeader` or `NextUrl`, lazy, loop-safe | [pagination](docs/pagination.md) |
|
|
185
|
+
| Errors | One `ClientError` tree; method, URL, request id, attempts, `retryable`, `retry_after`, RFC 9457 problems | [errors](docs/errors.md) |
|
|
186
|
+
| Auth | Static headers, query keys, and a cached minted token with one re-mint on a 401 | [auth](docs/auth.md) |
|
|
187
|
+
| Observability | Typed events, OpenTelemetry spans and metrics, request ids, `Deprecation` / `Sunset` notices, health snapshots | [observability](docs/observability.md) |
|
|
188
|
+
| Testing kit | `FakeTransport`, `FaultyTransport`, `TestClock`, `isolated_breakers`, vcrpy cassette filters | [testing](docs/testing.md) |
|
|
189
|
+
| Per-call options | `retry=`, `deadline=`, `timeout=`, `idempotency_key=`, `cache_ttl=`, and `with_options()` views | [configuration](docs/configuration.md#per-call-options) |
|
|
190
|
+
|
|
191
|
+
How the pieces fit together, for contributors and the curious: [design](docs/design.md).
|
|
192
|
+
|
|
193
|
+
## The contracts
|
|
194
|
+
|
|
195
|
+
1. **Declared answers, or raise.** A call returns what its reader read from a 2xx or from a status it declared, and
|
|
196
|
+
raises a `ClientError` for anything else. There is no raw mode and no `None` for "it failed".
|
|
197
|
+
2. **Transport only.** A client does I/O and decoding. Retries, pacing and the breaker live in the transport stack;
|
|
198
|
+
business decisions belong to the caller.
|
|
199
|
+
3. **Wire types are data.** Responses decode into plain typed values (msgspec structs or pydantic models) with no
|
|
200
|
+
behaviour.
|
|
201
|
+
4. **No logging; typed events instead.** headgate writes no log records. It reports to the observers you install,
|
|
202
|
+
with sanitized data only, and its errors carry what you need to diagnose a failure.
|
|
203
|
+
5. **No environment reads.** Credentials are passed in. No proxy variable, `.netrc` or `SSL_CERT_FILE` is read: the
|
|
204
|
+
HTTP client and its transport are built with `trust_env=False`.
|
|
205
|
+
|
|
206
|
+
## Configuration
|
|
207
|
+
|
|
208
|
+
`AsyncBaseClient(*, base_url, rate, auth=None, policy=TransportPolicy(), http=HttpOptions())`. `rate` is a `Rate` (a
|
|
209
|
+
budget of the client's own) or a `RateBudget` shared with other clients. `TransportPolicy` says what the stack does to
|
|
210
|
+
every request, `HttpOptions` how requests are sent and read. Both are frozen and validate on construction.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
class OrdersClient(AsyncBaseClient):
|
|
214
|
+
"""The shop's order API."""
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
client_id: str,
|
|
219
|
+
client_secret: str,
|
|
220
|
+
*,
|
|
221
|
+
observers: tuple[Observer, ...] = (),
|
|
222
|
+
transport: httpx2.AsyncBaseTransport | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Mint tokens with *client_id* and *client_secret*; report to *observers*; *transport* replaces the network."""
|
|
225
|
+
super().__init__(
|
|
226
|
+
base_url="https://api.shop.example/v2",
|
|
227
|
+
rate=Rate.per_second(20),
|
|
228
|
+
auth=ShopTokenAuth(client_id, client_secret),
|
|
229
|
+
policy=TransportPolicy(
|
|
230
|
+
retry=RetryPolicy(attempts=4), # four sends at most, the first included
|
|
231
|
+
deadline=30.0, # each call, every attempt and wait included
|
|
232
|
+
idempotency=IdempotencyKeys(), # every POST and PATCH carries a key, so it is retried safely
|
|
233
|
+
rate_limits=(RateRule("/exports", Rate.per_minute(6)),), # a slower lane for the export endpoint
|
|
234
|
+
routes=(Route("/exports", deadline=300.0),), # and a longer deadline
|
|
235
|
+
),
|
|
236
|
+
http=HttpOptions(
|
|
237
|
+
transport=transport,
|
|
238
|
+
timeout=Timeouts(read=20.0),
|
|
239
|
+
user_agent="shop-sync/1.4",
|
|
240
|
+
observers=observers,
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
| `TransportPolicy` | Default | | `HttpOptions` | Default |
|
|
246
|
+
| --- | --- | --- | --- | --- |
|
|
247
|
+
| `retry` | `RetryPolicy()`: 3 attempts | | `timeout` | `Timeouts(connect=10, read=30, write=10, pool=15)` |
|
|
248
|
+
| `deadline` | `60.0` seconds per call | | `headers` | none beyond `User-Agent` |
|
|
249
|
+
| `breaker` | `BreakerPolicy()`: 5 failures, 60 s | | `follow_redirects`, `max_redirects` | `True`, `5` |
|
|
250
|
+
| `rate_limits` | none | | `max_response_bytes` | 32 MiB |
|
|
251
|
+
| `cache` | none | | `trusted_origins` | `base_url`'s origin only |
|
|
252
|
+
| `idempotency` | none | | `allow_private_hosts` | `True` (SSRF guard off) |
|
|
253
|
+
| `routes` | none | | `observers`, `middleware` | none |
|
|
254
|
+
|
|
255
|
+
Every option, its validation and the precedence of call, view, route and policy:
|
|
256
|
+
[configuration](docs/configuration.md).
|
|
257
|
+
|
|
258
|
+
## Per-call options
|
|
259
|
+
|
|
260
|
+
Every verb (`get`, `post`, `put`, `patch`, `delete`, `head`) takes the URL, `expect=`, and optionally `retry=False` or
|
|
261
|
+
a `RetryPolicy`, `follow_redirects=`, `cache_ttl=`, `timeout=`, `deadline=`, `idempotency_key=`, and the request
|
|
262
|
+
options `params=`, `headers=`, `json=`, `data=`, `content=` and `files=`. `client.with_options(headers=...,
|
|
263
|
+
timeout=..., deadline=..., retry=...)` returns a view with other defaults that shares the pool, the budgets and the
|
|
264
|
+
breakers; the client it came from owns the pool.
|
|
265
|
+
|
|
266
|
+
## Errors
|
|
267
|
+
|
|
268
|
+
Every failure is a `ClientError`, so an application boundary can map the tree onto its own answers:
|
|
269
|
+
|
|
270
|
+
```text
|
|
271
|
+
ClientError also: an unparseable URL, a pagination loop
|
|
272
|
+
├── TransportError a network failure that outlasted retries
|
|
273
|
+
│ ├── ClientTimeoutError a timeout, or the call's deadline
|
|
274
|
+
│ └── CircuitOpenError the origin's breaker refused the call unsent
|
|
275
|
+
├── UpstreamStatusError an undeclared status; a 5xx that outlasted retries; a refusal in a body
|
|
276
|
+
│ ├── AuthError 401 or 403: our credential was refused
|
|
277
|
+
│ └── RateLimitError 429, a quota refusal, or a server pause past the deadline
|
|
278
|
+
├── ResponseParseError a 2xx body the reader could not read
|
|
279
|
+
├── ResponseTooLargeError a body past max_response_bytes
|
|
280
|
+
├── ForbiddenDestinationError a hop the SSRF guard refused
|
|
281
|
+
└── ClientConfigurationError our own setup is wrong
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Every error carries `method`, `url` (credentials redacted), `request_id`, `attempts`, `retryable`, `retry_after` and a
|
|
285
|
+
note per attempt (`attempt 2/3: 503 Service Unavailable (Retry-After 2.0s) after 0.41s`). An `UpstreamStatusError`
|
|
286
|
+
adds `status_code`, the provider's `code` and its RFC 9457 `problem`. All of them pickle.
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
def gateway_status(error: ClientError) -> HTTPStatus:
|
|
290
|
+
"""Return the status an API gateway answers with when a call to an upstream failed with *error*."""
|
|
291
|
+
match error:
|
|
292
|
+
case ClientTimeoutError():
|
|
293
|
+
return HTTPStatus.GATEWAY_TIMEOUT # the upstream was too slow, or the call's deadline passed
|
|
294
|
+
case ClientConfigurationError():
|
|
295
|
+
return HTTPStatus.SERVICE_UNAVAILABLE # our own setup is wrong; an operator must act
|
|
296
|
+
case _:
|
|
297
|
+
return HTTPStatus.BAD_GATEWAY # the upstream failed, refused us, or sent something unreadable
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
More in [errors](docs/errors.md).
|
|
301
|
+
|
|
302
|
+
## Testing your client
|
|
303
|
+
|
|
304
|
+
`headgate.testing` tests a client through its real stack, with nothing private patched: a `FakeTransport` stands in
|
|
305
|
+
for the network, and a `TestClock` makes every wait instant.
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
import httpx2
|
|
309
|
+
from quickstart import CatalogClient
|
|
310
|
+
|
|
311
|
+
from headgate.testing import FakeTransport, TestClock, isolated_breakers
|
|
312
|
+
|
|
313
|
+
GDP = {"id": "GDP", "title": "Gross domestic product", "values": [27.4, 28.1]}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def test_find_series_reads_a_404_as_none() -> None:
|
|
317
|
+
"""Return ``None`` for the 404 the endpoint documents, signing the request with the key."""
|
|
318
|
+
fake = FakeTransport().route("GET", "/v1/series/RETIRED", 404)
|
|
319
|
+
async with CatalogClient("test-key", transport=fake) as catalog:
|
|
320
|
+
assert await catalog.find_series("RETIRED") is None
|
|
321
|
+
assert fake.calls[0].headers["Authorization"] == "Bearer test-key"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def test_a_503_is_retried_after_the_wait_it_asks_for() -> None:
|
|
325
|
+
"""Retry a 503 once its ``Retry-After`` has passed, on a test clock that makes the wait instant."""
|
|
326
|
+
fake = FakeTransport().route(
|
|
327
|
+
"GET",
|
|
328
|
+
"/v1/series/GDP",
|
|
329
|
+
httpx2.Response(503, headers={"Retry-After": "2"}),
|
|
330
|
+
httpx2.Response(200, json=GDP),
|
|
331
|
+
)
|
|
332
|
+
clock = TestClock()
|
|
333
|
+
with clock.install(), isolated_breakers(): # the clients built in the block use them
|
|
334
|
+
catalog = CatalogClient("test-key", transport=fake)
|
|
335
|
+
async with catalog:
|
|
336
|
+
series = await catalog.get_series("GDP")
|
|
337
|
+
assert series.values == [27.4, 28.1]
|
|
338
|
+
assert clock.sleeps == [2.0]
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Fault schedules, cassettes and switching the policy off: [testing](docs/testing.md).
|
|
342
|
+
|
|
343
|
+
## Codec flavors
|
|
344
|
+
|
|
345
|
+
The core imports no codec. A client picks one by import, and both flavors expose `decode`, `decoder` and
|
|
346
|
+
`to_builtins` with the same behaviour: strict typing (a field that changes type fails), unknown fields ignored, and a
|
|
347
|
+
mismatch raised as `DecodeError`, which the client turns into `ResponseParseError`.
|
|
348
|
+
|
|
349
|
+
| Import | Decodes into | Install |
|
|
350
|
+
| --- | --- | --- |
|
|
351
|
+
| `headgate.msgspec` | `msgspec.Struct` and any msgspec type | core |
|
|
352
|
+
| `headgate.pydantic` | `pydantic.BaseModel` and any pydantic type | `headgate[pydantic]` |
|
|
353
|
+
|
|
354
|
+
## Development
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
uv sync # Python 3.14 and every dev dependency
|
|
358
|
+
uv run ruff check && uv run ruff format --check
|
|
359
|
+
uv run ty check
|
|
360
|
+
uv run python -m pytest --cov # 100% line and branch coverage is required
|
|
361
|
+
uv run python -m pytest benchmarks # the benchmarks, as CI runs them
|
|
362
|
+
uv run mutmut run # mutation testing; slow, run it before a release
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Lint selects every ruff rule, ty treats every rule as an error, and suppression comments are not accepted. See
|
|
366
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for the rules a change must keep, [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for how we
|
|
367
|
+
work together, and [SECURITY.md](SECURITY.md) to report a vulnerability privately. The changes in each release are in
|
|
368
|
+
[CHANGELOG.md](CHANGELOG.md).
|
|
369
|
+
|
|
370
|
+
## Licence
|
|
371
|
+
|
|
372
|
+
MIT. `src/headgate/_curl.py` is derived from [httpx-curl-cffi](https://github.com/vgavro/httpx-curl-cffi)
|
|
373
|
+
(BSD-3-Clause); its notice is kept in the file.
|