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.
Files changed (253) hide show
  1. headgate-0.2.0/.gitignore +14 -0
  2. headgate-0.2.0/CHANGELOG.md +190 -0
  3. headgate-0.2.0/LICENSE +21 -0
  4. headgate-0.2.0/PKG-INFO +373 -0
  5. headgate-0.2.0/README.md +334 -0
  6. headgate-0.2.0/docs/auth.md +99 -0
  7. headgate-0.2.0/docs/breaker.md +74 -0
  8. headgate-0.2.0/docs/caching.md +111 -0
  9. headgate-0.2.0/docs/configuration.md +179 -0
  10. headgate-0.2.0/docs/deadlines.md +54 -0
  11. headgate-0.2.0/docs/design.md +144 -0
  12. headgate-0.2.0/docs/errors.md +78 -0
  13. headgate-0.2.0/docs/mutation-testing.md +172 -0
  14. headgate-0.2.0/docs/observability.md +107 -0
  15. headgate-0.2.0/docs/pagination.md +74 -0
  16. headgate-0.2.0/docs/rate-budgets.md +84 -0
  17. headgate-0.2.0/docs/responses.md +133 -0
  18. headgate-0.2.0/docs/retries.md +147 -0
  19. headgate-0.2.0/docs/security.md +107 -0
  20. headgate-0.2.0/docs/streaming.md +81 -0
  21. headgate-0.2.0/docs/testing.md +136 -0
  22. headgate-0.2.0/examples/__init__.py +4 -0
  23. headgate-0.2.0/examples/boundary.py +19 -0
  24. headgate-0.2.0/examples/orders_client.py +163 -0
  25. headgate-0.2.0/examples/quickstart.py +93 -0
  26. headgate-0.2.0/examples/series_client.py +73 -0
  27. headgate-0.2.0/pyproject.toml +155 -0
  28. headgate-0.2.0/src/headgate/__init__.py +131 -0
  29. headgate-0.2.0/src/headgate/_attempts.py +50 -0
  30. headgate-0.2.0/src/headgate/_auth.py +504 -0
  31. headgate-0.2.0/src/headgate/_body.py +115 -0
  32. headgate-0.2.0/src/headgate/_client.py +927 -0
  33. headgate-0.2.0/src/headgate/_codec.py +24 -0
  34. headgate-0.2.0/src/headgate/_core/__init__.py +88 -0
  35. headgate-0.2.0/src/headgate/_core/_context.py +228 -0
  36. headgate-0.2.0/src/headgate/_core/_events.py +248 -0
  37. headgate-0.2.0/src/headgate/_core/_origin.py +39 -0
  38. headgate-0.2.0/src/headgate/_core/_retry_after.py +76 -0
  39. headgate-0.2.0/src/headgate/_core/_seams.py +50 -0
  40. headgate-0.2.0/src/headgate/_core/_switch.py +40 -0
  41. headgate-0.2.0/src/headgate/_core/_time.py +102 -0
  42. headgate-0.2.0/src/headgate/_curl.py +287 -0
  43. headgate-0.2.0/src/headgate/_deprecation.py +101 -0
  44. headgate-0.2.0/src/headgate/_failures.py +439 -0
  45. headgate-0.2.0/src/headgate/_middleware.py +90 -0
  46. headgate-0.2.0/src/headgate/_options.py +358 -0
  47. headgate-0.2.0/src/headgate/_redirects.py +86 -0
  48. headgate-0.2.0/src/headgate/_request_body.py +81 -0
  49. headgate-0.2.0/src/headgate/_responses.py +290 -0
  50. headgate-0.2.0/src/headgate/_stack.py +185 -0
  51. headgate-0.2.0/src/headgate/_url.py +258 -0
  52. headgate-0.2.0/src/headgate/_user_agent.py +30 -0
  53. headgate-0.2.0/src/headgate/_view.py +105 -0
  54. headgate-0.2.0/src/headgate/breaker/__init__.py +24 -0
  55. headgate-0.2.0/src/headgate/breaker/_breaker.py +302 -0
  56. headgate-0.2.0/src/headgate/breaker/_policy.py +37 -0
  57. headgate-0.2.0/src/headgate/breaker/_registry.py +168 -0
  58. headgate-0.2.0/src/headgate/breaker/_scope.py +43 -0
  59. headgate-0.2.0/src/headgate/cache/__init__.py +35 -0
  60. headgate-0.2.0/src/headgate/cache/_conditional.py +89 -0
  61. headgate-0.2.0/src/headgate/cache/_credentials.py +143 -0
  62. headgate-0.2.0/src/headgate/cache/_policy.py +131 -0
  63. headgate-0.2.0/src/headgate/cache/_transport.py +568 -0
  64. headgate-0.2.0/src/headgate/curl.py +8 -0
  65. headgate-0.2.0/src/headgate/errors.py +329 -0
  66. headgate-0.2.0/src/headgate/events.py +45 -0
  67. headgate-0.2.0/src/headgate/health.py +73 -0
  68. headgate-0.2.0/src/headgate/msgspec.py +125 -0
  69. headgate-0.2.0/src/headgate/otel.py +236 -0
  70. headgate-0.2.0/src/headgate/pagination/__init__.py +25 -0
  71. headgate-0.2.0/src/headgate/pagination/_link.py +141 -0
  72. headgate-0.2.0/src/headgate/pagination/_pager.py +197 -0
  73. headgate-0.2.0/src/headgate/pagination/_paginated.py +82 -0
  74. headgate-0.2.0/src/headgate/policy.py +43 -0
  75. headgate-0.2.0/src/headgate/py.typed +0 -0
  76. headgate-0.2.0/src/headgate/pydantic.py +131 -0
  77. headgate-0.2.0/src/headgate/ratelimit/__init__.py +23 -0
  78. headgate-0.2.0/src/headgate/ratelimit/_budget.py +275 -0
  79. headgate-0.2.0/src/headgate/ratelimit/_budgets.py +62 -0
  80. headgate-0.2.0/src/headgate/ratelimit/_feedback.py +133 -0
  81. headgate-0.2.0/src/headgate/ratelimit/_fields.py +187 -0
  82. headgate-0.2.0/src/headgate/ratelimit/_inflight.py +92 -0
  83. headgate-0.2.0/src/headgate/ratelimit/_rate.py +103 -0
  84. headgate-0.2.0/src/headgate/ratelimit/_transport.py +106 -0
  85. headgate-0.2.0/src/headgate/retry/__init__.py +40 -0
  86. headgate-0.2.0/src/headgate/retry/_budget.py +73 -0
  87. headgate-0.2.0/src/headgate/retry/_classify.py +135 -0
  88. headgate-0.2.0/src/headgate/retry/_policy.py +254 -0
  89. headgate-0.2.0/src/headgate/retry/_tracking.py +121 -0
  90. headgate-0.2.0/src/headgate/retry/_transport.py +448 -0
  91. headgate-0.2.0/src/headgate/security/__init__.py +36 -0
  92. headgate-0.2.0/src/headgate/security/_addresses.py +120 -0
  93. headgate-0.2.0/src/headgate/security/_credentials.py +124 -0
  94. headgate-0.2.0/src/headgate/security/_downgrade.py +42 -0
  95. headgate-0.2.0/src/headgate/security/_transport.py +119 -0
  96. headgate-0.2.0/src/headgate/streaming/__init__.py +12 -0
  97. headgate-0.2.0/src/headgate/streaming/_stream.py +164 -0
  98. headgate-0.2.0/src/headgate/testing/__init__.py +58 -0
  99. headgate-0.2.0/src/headgate/testing/_breakers.py +33 -0
  100. headgate-0.2.0/src/headgate/testing/_recording.py +120 -0
  101. headgate-0.2.0/src/headgate/testing/_time.py +84 -0
  102. headgate-0.2.0/src/headgate/testing/_transport.py +115 -0
  103. headgate-0.2.0/src/headgate/testing/faults.py +275 -0
  104. headgate-0.2.0/tests/__init__.py +4 -0
  105. headgate-0.2.0/tests/auth/__init__.py +4 -0
  106. headgate-0.2.0/tests/auth/test_cached_token_auth.py +619 -0
  107. headgate-0.2.0/tests/auth/test_credential_stampers.py +238 -0
  108. headgate-0.2.0/tests/auth/test_flows_are_uniform.py +55 -0
  109. headgate-0.2.0/tests/auth/test_header_and_query_auth.py +91 -0
  110. headgate-0.2.0/tests/auth/test_mint_failures.py +440 -0
  111. headgate-0.2.0/tests/breaker/__init__.py +4 -0
  112. headgate-0.2.0/tests/breaker/test_breaker.py +420 -0
  113. headgate-0.2.0/tests/breaker/test_breaker_charging.py +244 -0
  114. headgate-0.2.0/tests/breaker/test_breaker_integration.py +398 -0
  115. headgate-0.2.0/tests/breaker/test_breaker_keying.py +152 -0
  116. headgate-0.2.0/tests/breaker/test_breaker_state_machine.py +151 -0
  117. headgate-0.2.0/tests/breaker/test_registry.py +173 -0
  118. headgate-0.2.0/tests/cache/__init__.py +4 -0
  119. headgate-0.2.0/tests/cache/_helpers.py +191 -0
  120. headgate-0.2.0/tests/cache/test_conditional.py +140 -0
  121. headgate-0.2.0/tests/cache/test_credentials.py +406 -0
  122. headgate-0.2.0/tests/cache/test_events.py +149 -0
  123. headgate-0.2.0/tests/cache/test_extension_passthrough.py +121 -0
  124. headgate-0.2.0/tests/cache/test_fallback.py +505 -0
  125. headgate-0.2.0/tests/cache/test_filters.py +56 -0
  126. headgate-0.2.0/tests/cache/test_held_bodies.py +255 -0
  127. headgate-0.2.0/tests/cache/test_integration.py +227 -0
  128. headgate-0.2.0/tests/cache/test_shared_downloads.py +495 -0
  129. headgate-0.2.0/tests/client/__init__.py +4 -0
  130. headgate-0.2.0/tests/client/test_call_options.py +181 -0
  131. headgate-0.2.0/tests/client/test_client.py +822 -0
  132. headgate-0.2.0/tests/client/test_deadline.py +97 -0
  133. headgate-0.2.0/tests/client/test_error_details.py +569 -0
  134. headgate-0.2.0/tests/client/test_error_messages.py +305 -0
  135. headgate-0.2.0/tests/client/test_events.py +183 -0
  136. headgate-0.2.0/tests/client/test_failure_policy.py +186 -0
  137. headgate-0.2.0/tests/client/test_invalid_url.py +52 -0
  138. headgate-0.2.0/tests/client/test_middleware.py +172 -0
  139. headgate-0.2.0/tests/client/test_options.py +451 -0
  140. headgate-0.2.0/tests/client/test_policy.py +182 -0
  141. headgate-0.2.0/tests/client/test_redirect_identity.py +71 -0
  142. headgate-0.2.0/tests/client/test_response_size_cap.py +324 -0
  143. headgate-0.2.0/tests/client/test_retry_opt_out.py +110 -0
  144. headgate-0.2.0/tests/client/test_verbs.py +209 -0
  145. headgate-0.2.0/tests/conftest.py +70 -0
  146. headgate-0.2.0/tests/core/__init__.py +4 -0
  147. headgate-0.2.0/tests/core/test_context.py +118 -0
  148. headgate-0.2.0/tests/core/test_events.py +45 -0
  149. headgate-0.2.0/tests/core/test_origin.py +38 -0
  150. headgate-0.2.0/tests/core/test_retry_after.py +70 -0
  151. headgate-0.2.0/tests/decoding/__init__.py +4 -0
  152. headgate-0.2.0/tests/decoding/test_errors.py +126 -0
  153. headgate-0.2.0/tests/decoding/test_event_stream.py +66 -0
  154. headgate-0.2.0/tests/decoding/test_msgspec.py +250 -0
  155. headgate-0.2.0/tests/decoding/test_pydantic.py +263 -0
  156. headgate-0.2.0/tests/decoding/test_responses.py +251 -0
  157. headgate-0.2.0/tests/errors/__init__.py +4 -0
  158. headgate-0.2.0/tests/errors/__snapshots__/test_error_messages_snapshot.ambr +467 -0
  159. headgate-0.2.0/tests/errors/test_error_messages_snapshot.py +399 -0
  160. headgate-0.2.0/tests/examples/__init__.py +4 -0
  161. headgate-0.2.0/tests/examples/test_docs.py +146 -0
  162. headgate-0.2.0/tests/examples/test_orders_client.py +112 -0
  163. headgate-0.2.0/tests/examples/test_quickstart.py +118 -0
  164. headgate-0.2.0/tests/examples/test_series_client.py +40 -0
  165. headgate-0.2.0/tests/integration/__init__.py +4 -0
  166. headgate-0.2.0/tests/integration/_support.py +107 -0
  167. headgate-0.2.0/tests/integration/test_breaker_gate.py +71 -0
  168. headgate-0.2.0/tests/integration/test_cache_and_auths.py +108 -0
  169. headgate-0.2.0/tests/integration/test_deadline.py +91 -0
  170. headgate-0.2.0/tests/integration/test_destinations.py +99 -0
  171. headgate-0.2.0/tests/integration/test_events.py +85 -0
  172. headgate-0.2.0/tests/integration/test_hedging.py +120 -0
  173. headgate-0.2.0/tests/integration/test_pagination_credentials.py +59 -0
  174. headgate-0.2.0/tests/integration/test_replayable_body.py +140 -0
  175. headgate-0.2.0/tests/integration/test_retry_and_rate.py +60 -0
  176. headgate-0.2.0/tests/integration/test_views.py +94 -0
  177. headgate-0.2.0/tests/observe/__init__.py +4 -0
  178. headgate-0.2.0/tests/observe/__snapshots__/test_snapshots.ambr +703 -0
  179. headgate-0.2.0/tests/observe/_support.py +46 -0
  180. headgate-0.2.0/tests/observe/test_accept.py +129 -0
  181. headgate-0.2.0/tests/observe/test_deprecation.py +124 -0
  182. headgate-0.2.0/tests/observe/test_otel.py +416 -0
  183. headgate-0.2.0/tests/observe/test_otel_isolation.py +41 -0
  184. headgate-0.2.0/tests/observe/test_snapshots.py +183 -0
  185. headgate-0.2.0/tests/observe/test_with_options.py +229 -0
  186. headgate-0.2.0/tests/package/__init__.py +4 -0
  187. headgate-0.2.0/tests/package/__snapshots__/test_api_surface.ambr +717 -0
  188. headgate-0.2.0/tests/package/test_api_surface.py +150 -0
  189. headgate-0.2.0/tests/package/test_boundaries.py +117 -0
  190. headgate-0.2.0/tests/package/test_contracts.py +33 -0
  191. headgate-0.2.0/tests/package/test_import_isolation.py +54 -0
  192. headgate-0.2.0/tests/package/test_metadata.py +46 -0
  193. headgate-0.2.0/tests/pagination/__init__.py +4 -0
  194. headgate-0.2.0/tests/pagination/test_link_header.py +133 -0
  195. headgate-0.2.0/tests/pagination/test_paginate.py +359 -0
  196. headgate-0.2.0/tests/performance/__init__.py +4 -0
  197. headgate-0.2.0/tests/performance/test_memory.py +182 -0
  198. headgate-0.2.0/tests/properties/__init__.py +4 -0
  199. headgate-0.2.0/tests/properties/test_policy_properties.py +87 -0
  200. headgate-0.2.0/tests/properties/test_url_properties.py +79 -0
  201. headgate-0.2.0/tests/ratelimit/__init__.py +4 -0
  202. headgate-0.2.0/tests/ratelimit/test_budget.py +536 -0
  203. headgate-0.2.0/tests/ratelimit/test_client_feedback.py +122 -0
  204. headgate-0.2.0/tests/ratelimit/test_feedback.py +230 -0
  205. headgate-0.2.0/tests/ratelimit/test_in_flight.py +267 -0
  206. headgate-0.2.0/tests/ratelimit/test_rate.py +75 -0
  207. headgate-0.2.0/tests/ratelimit/test_rate_limit_transport.py +222 -0
  208. headgate-0.2.0/tests/retry/__init__.py +4 -0
  209. headgate-0.2.0/tests/retry/test_budget_and_classify.py +172 -0
  210. headgate-0.2.0/tests/retry/test_client_retries.py +205 -0
  211. headgate-0.2.0/tests/retry/test_faults.py +322 -0
  212. headgate-0.2.0/tests/retry/test_parking_and_hedging.py +415 -0
  213. headgate-0.2.0/tests/retry/test_policy.py +145 -0
  214. headgate-0.2.0/tests/retry/test_properties.py +237 -0
  215. headgate-0.2.0/tests/retry/test_tracking.py +294 -0
  216. headgate-0.2.0/tests/retry/test_transport.py +845 -0
  217. headgate-0.2.0/tests/security/__init__.py +4 -0
  218. headgate-0.2.0/tests/security/_servers.py +154 -0
  219. headgate-0.2.0/tests/security/_web.py +51 -0
  220. headgate-0.2.0/tests/security/certs/ca.pem +10 -0
  221. headgate-0.2.0/tests/security/certs/server.pem +17 -0
  222. headgate-0.2.0/tests/security/test_addresses.py +132 -0
  223. headgate-0.2.0/tests/security/test_credential_origins.py +287 -0
  224. headgate-0.2.0/tests/security/test_origin_properties.py +122 -0
  225. headgate-0.2.0/tests/security/test_pinned_transport.py +164 -0
  226. headgate-0.2.0/tests/security/test_redirect_hardening.py +155 -0
  227. headgate-0.2.0/tests/security/test_ssrf_guard.py +207 -0
  228. headgate-0.2.0/tests/streaming/__init__.py +4 -0
  229. headgate-0.2.0/tests/streaming/test_request_bodies.py +169 -0
  230. headgate-0.2.0/tests/streaming/test_stream.py +412 -0
  231. headgate-0.2.0/tests/support/__init__.py +4 -0
  232. headgate-0.2.0/tests/support/breakers.py +20 -0
  233. headgate-0.2.0/tests/support/budgets.py +53 -0
  234. headgate-0.2.0/tests/support/clients.py +12 -0
  235. headgate-0.2.0/tests/support/events.py +27 -0
  236. headgate-0.2.0/tests/support/responses.py +31 -0
  237. headgate-0.2.0/tests/support/tokens.py +24 -0
  238. headgate-0.2.0/tests/support/wire.py +29 -0
  239. headgate-0.2.0/tests/testing/__init__.py +4 -0
  240. headgate-0.2.0/tests/testing/test_breakers.py +96 -0
  241. headgate-0.2.0/tests/testing/test_cassettes.py +103 -0
  242. headgate-0.2.0/tests/testing/test_fake_transport.py +114 -0
  243. headgate-0.2.0/tests/testing/test_recording.py +111 -0
  244. headgate-0.2.0/tests/testing/test_time.py +80 -0
  245. headgate-0.2.0/tests/transport/__init__.py +4 -0
  246. headgate-0.2.0/tests/transport/test_curl.py +484 -0
  247. headgate-0.2.0/tests/transport/test_health.py +143 -0
  248. headgate-0.2.0/tests/urls/__init__.py +4 -0
  249. headgate-0.2.0/tests/urls/test_empty_segment.py +34 -0
  250. headgate-0.2.0/tests/urls/test_path_escaping.py +234 -0
  251. headgate-0.2.0/tests/urls/test_route_template.py +24 -0
  252. headgate-0.2.0/tests/urls/test_sanitize_url.py +114 -0
  253. headgate-0.2.0/tests/urls/test_secret_query_params.py +89 -0
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ *.egg-info/
8
+ .coverage
9
+ .coverage.*
10
+ htmlcov/
11
+ .codspeed/
12
+ .benchmarks/
13
+ mutants/
14
+ .hypothesis/
@@ -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.
@@ -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.