headgate 0.2.0__tar.gz → 0.4.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 (260) hide show
  1. {headgate-0.2.0 → headgate-0.4.0}/.gitignore +3 -0
  2. {headgate-0.2.0 → headgate-0.4.0}/CHANGELOG.md +55 -2
  3. {headgate-0.2.0 → headgate-0.4.0}/PKG-INFO +25 -4
  4. {headgate-0.2.0 → headgate-0.4.0}/README.md +23 -2
  5. {headgate-0.2.0 → headgate-0.4.0}/docs/auth.md +1 -1
  6. {headgate-0.2.0 → headgate-0.4.0}/docs/caching.md +14 -7
  7. headgate-0.4.0/docs/comparison.md +50 -0
  8. {headgate-0.2.0 → headgate-0.4.0}/docs/deadlines.md +18 -1
  9. headgate-0.4.0/docs/index.md +158 -0
  10. {headgate-0.2.0 → headgate-0.4.0}/docs/pagination.md +32 -3
  11. {headgate-0.2.0 → headgate-0.4.0}/docs/rate-budgets.md +2 -1
  12. headgate-0.4.0/docs/stylesheets/extra.css +91 -0
  13. {headgate-0.2.0 → headgate-0.4.0}/docs/testing.md +1 -1
  14. headgate-0.4.0/examples/github_client.py +86 -0
  15. {headgate-0.2.0 → headgate-0.4.0}/pyproject.toml +4 -3
  16. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/__init__.py +4 -1
  17. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_client.py +41 -10
  18. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/__init__.py +2 -1
  19. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_context.py +40 -1
  20. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_seams.py +17 -1
  21. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_options.py +26 -0
  22. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_transport.py +109 -32
  23. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/errors.py +15 -0
  24. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/__init__.py +2 -1
  25. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/_pager.py +23 -7
  26. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/_paginated.py +52 -7
  27. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/__init__.py +2 -0
  28. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_budget.py +7 -1
  29. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_transport.py +1 -0
  30. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_transport.py +37 -9
  31. {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_mint_failures.py +3 -2
  32. {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_charging.py +13 -3
  33. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/_helpers.py +8 -1
  34. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_credentials.py +3 -3
  35. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_fallback.py +80 -56
  36. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_held_bodies.py +2 -2
  37. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_shared_downloads.py +62 -0
  38. headgate-0.4.0/tests/cache/test_store_on_accept.py +146 -0
  39. headgate-0.4.0/tests/client/test_timeouts_fit_deadline.py +100 -0
  40. {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_context.py +27 -0
  41. headgate-0.4.0/tests/examples/test_github_client.py +76 -0
  42. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_deadline.py +50 -0
  43. {headgate-0.2.0 → headgate-0.4.0}/tests/package/__snapshots__/test_api_surface.ambr +28 -13
  44. {headgate-0.2.0 → headgate-0.4.0}/tests/pagination/test_paginate.py +144 -1
  45. {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_budget.py +25 -1
  46. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_parking_and_hedging.py +81 -1
  47. {headgate-0.2.0 → headgate-0.4.0}/LICENSE +0 -0
  48. {headgate-0.2.0 → headgate-0.4.0}/docs/breaker.md +0 -0
  49. {headgate-0.2.0 → headgate-0.4.0}/docs/configuration.md +0 -0
  50. {headgate-0.2.0 → headgate-0.4.0}/docs/design.md +0 -0
  51. {headgate-0.2.0 → headgate-0.4.0}/docs/errors.md +0 -0
  52. {headgate-0.2.0 → headgate-0.4.0}/docs/mutation-testing.md +0 -0
  53. {headgate-0.2.0 → headgate-0.4.0}/docs/observability.md +0 -0
  54. {headgate-0.2.0 → headgate-0.4.0}/docs/responses.md +0 -0
  55. {headgate-0.2.0 → headgate-0.4.0}/docs/retries.md +0 -0
  56. {headgate-0.2.0 → headgate-0.4.0}/docs/security.md +0 -0
  57. {headgate-0.2.0 → headgate-0.4.0}/docs/streaming.md +0 -0
  58. {headgate-0.2.0 → headgate-0.4.0}/examples/__init__.py +0 -0
  59. {headgate-0.2.0 → headgate-0.4.0}/examples/boundary.py +0 -0
  60. {headgate-0.2.0 → headgate-0.4.0}/examples/orders_client.py +0 -0
  61. {headgate-0.2.0 → headgate-0.4.0}/examples/quickstart.py +0 -0
  62. {headgate-0.2.0 → headgate-0.4.0}/examples/series_client.py +0 -0
  63. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_attempts.py +0 -0
  64. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_auth.py +0 -0
  65. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_body.py +0 -0
  66. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_codec.py +0 -0
  67. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_events.py +0 -0
  68. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_origin.py +0 -0
  69. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_retry_after.py +0 -0
  70. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_switch.py +0 -0
  71. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_time.py +0 -0
  72. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_curl.py +0 -0
  73. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_deprecation.py +0 -0
  74. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_failures.py +0 -0
  75. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_middleware.py +0 -0
  76. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_redirects.py +0 -0
  77. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_request_body.py +0 -0
  78. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_responses.py +0 -0
  79. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_stack.py +0 -0
  80. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_url.py +0 -0
  81. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_user_agent.py +0 -0
  82. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_view.py +0 -0
  83. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/__init__.py +0 -0
  84. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_breaker.py +0 -0
  85. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_policy.py +0 -0
  86. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_registry.py +0 -0
  87. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_scope.py +0 -0
  88. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/__init__.py +0 -0
  89. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_conditional.py +0 -0
  90. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_credentials.py +0 -0
  91. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_policy.py +0 -0
  92. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/curl.py +0 -0
  93. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/events.py +0 -0
  94. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/health.py +0 -0
  95. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/msgspec.py +0 -0
  96. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/otel.py +0 -0
  97. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/_link.py +0 -0
  98. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/policy.py +0 -0
  99. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/py.typed +0 -0
  100. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pydantic.py +0 -0
  101. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_budgets.py +0 -0
  102. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_feedback.py +0 -0
  103. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_fields.py +0 -0
  104. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_inflight.py +0 -0
  105. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_rate.py +0 -0
  106. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/__init__.py +0 -0
  107. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_budget.py +0 -0
  108. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_classify.py +0 -0
  109. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_policy.py +0 -0
  110. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_tracking.py +0 -0
  111. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/__init__.py +0 -0
  112. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_addresses.py +0 -0
  113. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_credentials.py +0 -0
  114. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_downgrade.py +0 -0
  115. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_transport.py +0 -0
  116. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/streaming/__init__.py +0 -0
  117. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/streaming/_stream.py +0 -0
  118. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/__init__.py +0 -0
  119. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_breakers.py +0 -0
  120. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_recording.py +0 -0
  121. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_time.py +0 -0
  122. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_transport.py +0 -0
  123. {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/faults.py +0 -0
  124. {headgate-0.2.0 → headgate-0.4.0}/tests/__init__.py +0 -0
  125. {headgate-0.2.0 → headgate-0.4.0}/tests/auth/__init__.py +0 -0
  126. {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_cached_token_auth.py +0 -0
  127. {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_credential_stampers.py +0 -0
  128. {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_flows_are_uniform.py +0 -0
  129. {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_header_and_query_auth.py +0 -0
  130. {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/__init__.py +0 -0
  131. {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker.py +0 -0
  132. {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_integration.py +0 -0
  133. {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_keying.py +0 -0
  134. {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_state_machine.py +0 -0
  135. {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_registry.py +0 -0
  136. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/__init__.py +0 -0
  137. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_conditional.py +0 -0
  138. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_events.py +0 -0
  139. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_extension_passthrough.py +0 -0
  140. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_filters.py +0 -0
  141. {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_integration.py +0 -0
  142. {headgate-0.2.0 → headgate-0.4.0}/tests/client/__init__.py +0 -0
  143. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_call_options.py +0 -0
  144. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_client.py +0 -0
  145. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_deadline.py +0 -0
  146. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_error_details.py +0 -0
  147. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_error_messages.py +0 -0
  148. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_events.py +0 -0
  149. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_failure_policy.py +0 -0
  150. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_invalid_url.py +0 -0
  151. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_middleware.py +0 -0
  152. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_options.py +0 -0
  153. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_policy.py +0 -0
  154. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_redirect_identity.py +0 -0
  155. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_response_size_cap.py +0 -0
  156. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_retry_opt_out.py +0 -0
  157. {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_verbs.py +0 -0
  158. {headgate-0.2.0 → headgate-0.4.0}/tests/conftest.py +0 -0
  159. {headgate-0.2.0 → headgate-0.4.0}/tests/core/__init__.py +0 -0
  160. {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_events.py +0 -0
  161. {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_origin.py +0 -0
  162. {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_retry_after.py +0 -0
  163. {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/__init__.py +0 -0
  164. {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_errors.py +0 -0
  165. {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_event_stream.py +0 -0
  166. {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_msgspec.py +0 -0
  167. {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_pydantic.py +0 -0
  168. {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_responses.py +0 -0
  169. {headgate-0.2.0 → headgate-0.4.0}/tests/errors/__init__.py +0 -0
  170. {headgate-0.2.0 → headgate-0.4.0}/tests/errors/__snapshots__/test_error_messages_snapshot.ambr +0 -0
  171. {headgate-0.2.0 → headgate-0.4.0}/tests/errors/test_error_messages_snapshot.py +0 -0
  172. {headgate-0.2.0 → headgate-0.4.0}/tests/examples/__init__.py +0 -0
  173. {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_docs.py +0 -0
  174. {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_orders_client.py +0 -0
  175. {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_quickstart.py +0 -0
  176. {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_series_client.py +0 -0
  177. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/__init__.py +0 -0
  178. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/_support.py +0 -0
  179. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_breaker_gate.py +0 -0
  180. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_cache_and_auths.py +0 -0
  181. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_destinations.py +0 -0
  182. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_events.py +0 -0
  183. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_hedging.py +0 -0
  184. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_pagination_credentials.py +0 -0
  185. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_replayable_body.py +0 -0
  186. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_retry_and_rate.py +0 -0
  187. {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_views.py +0 -0
  188. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/__init__.py +0 -0
  189. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/__snapshots__/test_snapshots.ambr +0 -0
  190. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/_support.py +0 -0
  191. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_accept.py +0 -0
  192. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_deprecation.py +0 -0
  193. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_otel.py +0 -0
  194. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_otel_isolation.py +0 -0
  195. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_snapshots.py +0 -0
  196. {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_with_options.py +0 -0
  197. {headgate-0.2.0 → headgate-0.4.0}/tests/package/__init__.py +0 -0
  198. {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_api_surface.py +0 -0
  199. {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_boundaries.py +0 -0
  200. {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_contracts.py +0 -0
  201. {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_import_isolation.py +0 -0
  202. {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_metadata.py +0 -0
  203. {headgate-0.2.0 → headgate-0.4.0}/tests/pagination/__init__.py +0 -0
  204. {headgate-0.2.0 → headgate-0.4.0}/tests/pagination/test_link_header.py +0 -0
  205. {headgate-0.2.0 → headgate-0.4.0}/tests/performance/__init__.py +0 -0
  206. {headgate-0.2.0 → headgate-0.4.0}/tests/performance/test_memory.py +0 -0
  207. {headgate-0.2.0 → headgate-0.4.0}/tests/properties/__init__.py +0 -0
  208. {headgate-0.2.0 → headgate-0.4.0}/tests/properties/test_policy_properties.py +0 -0
  209. {headgate-0.2.0 → headgate-0.4.0}/tests/properties/test_url_properties.py +0 -0
  210. {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/__init__.py +0 -0
  211. {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_client_feedback.py +0 -0
  212. {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_feedback.py +0 -0
  213. {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_in_flight.py +0 -0
  214. {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_rate.py +0 -0
  215. {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_rate_limit_transport.py +0 -0
  216. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/__init__.py +0 -0
  217. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_budget_and_classify.py +0 -0
  218. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_client_retries.py +0 -0
  219. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_faults.py +0 -0
  220. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_policy.py +0 -0
  221. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_properties.py +0 -0
  222. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_tracking.py +0 -0
  223. {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_transport.py +0 -0
  224. {headgate-0.2.0 → headgate-0.4.0}/tests/security/__init__.py +0 -0
  225. {headgate-0.2.0 → headgate-0.4.0}/tests/security/_servers.py +0 -0
  226. {headgate-0.2.0 → headgate-0.4.0}/tests/security/_web.py +0 -0
  227. {headgate-0.2.0 → headgate-0.4.0}/tests/security/certs/ca.pem +0 -0
  228. {headgate-0.2.0 → headgate-0.4.0}/tests/security/certs/server.pem +0 -0
  229. {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_addresses.py +0 -0
  230. {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_credential_origins.py +0 -0
  231. {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_origin_properties.py +0 -0
  232. {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_pinned_transport.py +0 -0
  233. {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_redirect_hardening.py +0 -0
  234. {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_ssrf_guard.py +0 -0
  235. {headgate-0.2.0 → headgate-0.4.0}/tests/streaming/__init__.py +0 -0
  236. {headgate-0.2.0 → headgate-0.4.0}/tests/streaming/test_request_bodies.py +0 -0
  237. {headgate-0.2.0 → headgate-0.4.0}/tests/streaming/test_stream.py +0 -0
  238. {headgate-0.2.0 → headgate-0.4.0}/tests/support/__init__.py +0 -0
  239. {headgate-0.2.0 → headgate-0.4.0}/tests/support/breakers.py +0 -0
  240. {headgate-0.2.0 → headgate-0.4.0}/tests/support/budgets.py +0 -0
  241. {headgate-0.2.0 → headgate-0.4.0}/tests/support/clients.py +0 -0
  242. {headgate-0.2.0 → headgate-0.4.0}/tests/support/events.py +0 -0
  243. {headgate-0.2.0 → headgate-0.4.0}/tests/support/responses.py +0 -0
  244. {headgate-0.2.0 → headgate-0.4.0}/tests/support/tokens.py +0 -0
  245. {headgate-0.2.0 → headgate-0.4.0}/tests/support/wire.py +0 -0
  246. {headgate-0.2.0 → headgate-0.4.0}/tests/testing/__init__.py +0 -0
  247. {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_breakers.py +0 -0
  248. {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_cassettes.py +0 -0
  249. {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_fake_transport.py +0 -0
  250. {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_recording.py +0 -0
  251. {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_time.py +0 -0
  252. {headgate-0.2.0 → headgate-0.4.0}/tests/transport/__init__.py +0 -0
  253. {headgate-0.2.0 → headgate-0.4.0}/tests/transport/test_curl.py +0 -0
  254. {headgate-0.2.0 → headgate-0.4.0}/tests/transport/test_health.py +0 -0
  255. {headgate-0.2.0 → headgate-0.4.0}/tests/urls/__init__.py +0 -0
  256. {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_empty_segment.py +0 -0
  257. {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_path_escaping.py +0 -0
  258. {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_route_template.py +0 -0
  259. {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_sanitize_url.py +0 -0
  260. {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_secret_query_params.py +0 -0
@@ -12,3 +12,6 @@ htmlcov/
12
12
  .benchmarks/
13
13
  mutants/
14
14
  .hypothesis/
15
+
16
+ # The docs site, built by `zensical build`
17
+ site/
@@ -6,6 +6,57 @@ change the public API; such changes are listed under **Changed** and **Removed**
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-10-26
10
+
11
+ Bounds `paginate()`: a page budget, and a stop at pages with no items. The stop changes what three pagers do by
12
+ default, so this is a minor release: see **Changed**.
13
+
14
+ ### Added
15
+
16
+ - A page budget for `paginate()`: `limit=PageLimit(max_pages)` raises the new `PageLimitError` once the budget is spent
17
+ with pages still to come, or with `on_limit="stop"` ends the walk there
18
+ ([#14](https://github.com/kelsayed17/headgate/issues/14)).
19
+ - A documentation site at <https://kelsayed17.github.io/headgate/>, built from `docs/`, with a page comparing headgate
20
+ with tenacity, stamina and a hand-rolled client.
21
+ - `examples/github_client.py`: a tested client for a few GitHub REST endpoints.
22
+
23
+ ### Changed
24
+
25
+ - `Cursor`, `NextUrl` and `LinkHeader` stop at a page with no items, as `Offset` already did, instead of following its
26
+ link: a link that advances while returning nothing made the walk spend requests for nothing. Pass
27
+ `stop_on_empty=False` for an endpoint that sends empty pages mid-walk
28
+ ([#15](https://github.com/kelsayed17/headgate/issues/15)).
29
+
30
+ ## [0.3.0] - 2026-10-26
31
+
32
+ Fixes four bugs where a call's deadline, its timeouts, the rate budget and the cache disagreed. One of them makes a
33
+ configuration that could never work an error, so this is a minor release: see **Changed**.
34
+
35
+ ### Added
36
+
37
+ - `headgate.ratelimit.BudgetBacklogError`, raised by `RateBudget.wait` when a call's paced turn comes after its deadline.
38
+
39
+ ### Changed
40
+
41
+ - A timeout longer than the deadline it runs under is refused with `ValueError` on the client, a view or a call, since
42
+ it could never take effect ([#6](https://github.com/kelsayed17/headgate/issues/6)). Raise the deadline with it, e.g.
43
+ `with_options(timeout=300.0, deadline=600.0)`.
44
+ - A body past `max_response_bytes` is no longer stored in the cache, since it is not held to be written on accept.
45
+
46
+ ### Fixed
47
+
48
+ - A cached miss is stored only once the call's reader accepts it, so a refusal inside a 200, an undecodable body or an
49
+ undeclared status is no longer served from the cache for the entry's lifetime
50
+ ([#9](https://github.com/kelsayed17/headgate/issues/9)). A `stream()` is accepted when its head checks pass.
51
+ - Identical concurrent GETs share one cached download again when their timeouts are longer than the time their deadline
52
+ leaves; the sharing key now uses the timeouts the call asked for, not the deadline-cut ones
53
+ ([#4](https://github.com/kelsayed17/headgate/issues/4)).
54
+ - A call whose rate-budget turn comes after its deadline fails at once with `ClientTimeoutError` saying it was never
55
+ sent, with `retryable=True` and `retry_after`, instead of waiting and failing like a slow upstream; a retry that
56
+ cannot be sent in time ends the call with the failure it already has, and such a hedge is not sent. A deadline that
57
+ passes before an attempt is sent says so, and `attempts` counts only the sends made
58
+ ([#5](https://github.com/kelsayed17/headgate/issues/5)).
59
+
9
60
  ## [0.2.0] - 2026-10-25
10
61
 
11
62
  The first release published to PyPI. It reshapes the client around option objects and rebuilds every policy
@@ -185,6 +236,8 @@ Python 3.14.
185
236
  - The `headgate.msgspec` and `headgate.pydantic` codec flavors, the optional `AsyncCurlTransport` (`headgate[curl]`),
186
237
  `headgate.health` snapshots and `headgate.policy.policy_disabled()`.
187
238
 
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
239
+ [Unreleased]: https://github.com/kelsayed17/headgate/compare/v0.4.0...HEAD
240
+ [0.4.0]: https://github.com/kelsayed17/headgate/compare/v0.3.0...v0.4.0
241
+ [0.3.0]: https://github.com/kelsayed17/headgate/compare/159a3c2...v0.3.0
242
+ [0.2.0]: https://github.com/kelsayed17/headgate/compare/f319ad8...159a3c2
190
243
  [0.1.0]: https://github.com/kelsayed17/headgate/commit/f319ad8
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: headgate
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
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
5
  Project-URL: Homepage, https://github.com/kelsayed17/headgate
6
- Project-URL: Documentation, https://github.com/kelsayed17/headgate/tree/main/docs
6
+ Project-URL: Documentation, https://kelsayed17.github.io/headgate/
7
7
  Project-URL: Repository, https://github.com/kelsayed17/headgate
8
8
  Project-URL: Issues, https://github.com/kelsayed17/headgate/issues
9
9
  Project-URL: Changelog, https://github.com/kelsayed17/headgate/blob/main/CHANGELOG.md
@@ -37,8 +37,19 @@ Provides-Extra: pydantic
37
37
  Requires-Dist: pydantic>=2.14.1; extra == 'pydantic'
38
38
  Description-Content-Type: text/markdown
39
39
 
40
+ <p align="center">
41
+ <img src="https://raw.githubusercontent.com/kelsayed17/headgate/main/docs/assets/logo.png" alt="headgate logo: a gate metering a stream of requests" width="160">
42
+ </p>
43
+
40
44
  # headgate
41
45
 
46
+ [![PyPI](https://img.shields.io/pypi/v/headgate)](https://pypi.org/project/headgate/)
47
+ [![Python](https://img.shields.io/pypi/pyversions/headgate)](https://pypi.org/project/headgate/)
48
+ [![CI](https://github.com/kelsayed17/headgate/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/kelsayed17/headgate/actions/workflows/ci.yml)
49
+ [![Coverage: 100%](https://img.shields.io/badge/coverage-100%25-brightgreen)](CONTRIBUTING.md)
50
+ [![Docs](https://img.shields.io/badge/docs-kelsayed17.github.io%2Fheadgate-blue)](https://kelsayed17.github.io/headgate/)
51
+ [![Licence: MIT](https://img.shields.io/pypi/l/headgate)](LICENSE)
52
+
42
53
  **An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
43
54
  `AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour:
44
55
  retries that re-send only when it is safe, a shared rate budget that listens to the server, a per-origin circuit
@@ -48,10 +59,19 @@ events and OpenTelemetry, and errors that say exactly what happened. Each endpoi
48
59
  A headgate controls how much water gets through a channel. This library does the same for requests, in one place,
49
60
  so that no client has to get it right on its own.
50
61
 
62
+ **Docs:** <https://kelsayed17.github.io/headgate/>. New here? See [how headgate compares](docs/comparison.md) with
63
+ tenacity, stamina and a hand-rolled client, and a [GitHub API client](examples/github_client.py) written with it.
64
+
51
65
  ## Requirements and install
52
66
 
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.
67
+ Python 3.14 or newer, and `httpx2`, pydantic's fork of httpx.
68
+
69
+ - **Why 3.14:** endpoint paths are [template strings](https://peps.python.org/pep-0750/) (`t"orders/{order_id}"`), so
70
+ each value is escaped into its own path segment by default: `BTC/USD` becomes `BTC%2FUSD` instead of a new path.
71
+ Template strings exist only from Python 3.14.
72
+ - **Why httpx2:** headgate is built and tested on it alone. Its API is httpx's, so moving a client over is mostly
73
+ `import httpx2` where you had `import httpx`. Upstream `httpx` is not supported: passing one of its objects raises a
74
+ `TypeError` naming the mistake. See [why httpx2](docs/comparison.md#why-httpx2).
55
75
 
56
76
  ```bash
57
77
  pip install headgate # or: uv add headgate. Brings httpx2[http2], hishel and msgspec
@@ -360,6 +380,7 @@ uv run ty check
360
380
  uv run python -m pytest --cov # 100% line and branch coverage is required
361
381
  uv run python -m pytest benchmarks # the benchmarks, as CI runs them
362
382
  uv run mutmut run # mutation testing; slow, run it before a release
383
+ uv run --only-group docs zensical serve # preview the docs site at http://localhost:8000
363
384
  ```
364
385
 
365
386
  Lint selects every ruff rule, ty treats every rule as an error, and suppression comments are not accepted. See
@@ -1,5 +1,16 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/kelsayed17/headgate/main/docs/assets/logo.png" alt="headgate logo: a gate metering a stream of requests" width="160">
3
+ </p>
4
+
1
5
  # headgate
2
6
 
7
+ [![PyPI](https://img.shields.io/pypi/v/headgate)](https://pypi.org/project/headgate/)
8
+ [![Python](https://img.shields.io/pypi/pyversions/headgate)](https://pypi.org/project/headgate/)
9
+ [![CI](https://github.com/kelsayed17/headgate/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/kelsayed17/headgate/actions/workflows/ci.yml)
10
+ [![Coverage: 100%](https://img.shields.io/badge/coverage-100%25-brightgreen)](CONTRIBUTING.md)
11
+ [![Docs](https://img.shields.io/badge/docs-kelsayed17.github.io%2Fheadgate-blue)](https://kelsayed17.github.io/headgate/)
12
+ [![Licence: MIT](https://img.shields.io/pypi/l/headgate)](LICENSE)
13
+
3
14
  **An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
4
15
  `AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour:
5
16
  retries that re-send only when it is safe, a shared rate budget that listens to the server, a per-origin circuit
@@ -9,10 +20,19 @@ events and OpenTelemetry, and errors that say exactly what happened. Each endpoi
9
20
  A headgate controls how much water gets through a channel. This library does the same for requests, in one place,
10
21
  so that no client has to get it right on its own.
11
22
 
23
+ **Docs:** <https://kelsayed17.github.io/headgate/>. New here? See [how headgate compares](docs/comparison.md) with
24
+ tenacity, stamina and a hand-rolled client, and a [GitHub API client](examples/github_client.py) written with it.
25
+
12
26
  ## Requirements and install
13
27
 
14
- Python 3.14 or newer (endpoint paths are t-strings) and `httpx2`, pydantic's fork of httpx. Upstream `httpx` is not
15
- supported: passing one of its objects raises a `TypeError` naming the mistake.
28
+ Python 3.14 or newer, and `httpx2`, pydantic's fork of httpx.
29
+
30
+ - **Why 3.14:** endpoint paths are [template strings](https://peps.python.org/pep-0750/) (`t"orders/{order_id}"`), so
31
+ each value is escaped into its own path segment by default: `BTC/USD` becomes `BTC%2FUSD` instead of a new path.
32
+ Template strings exist only from Python 3.14.
33
+ - **Why httpx2:** headgate is built and tested on it alone. Its API is httpx's, so moving a client over is mostly
34
+ `import httpx2` where you had `import httpx`. Upstream `httpx` is not supported: passing one of its objects raises a
35
+ `TypeError` naming the mistake. See [why httpx2](docs/comparison.md#why-httpx2).
16
36
 
17
37
  ```bash
18
38
  pip install headgate # or: uv add headgate. Brings httpx2[http2], hishel and msgspec
@@ -321,6 +341,7 @@ uv run ty check
321
341
  uv run python -m pytest --cov # 100% line and branch coverage is required
322
342
  uv run python -m pytest benchmarks # the benchmarks, as CI runs them
323
343
  uv run mutmut run # mutation testing; slow, run it before a release
344
+ uv run --only-group docs zensical serve # preview the docs site at http://localhost:8000
324
345
  ```
325
346
 
326
347
  Lint selects every ruff rule, ty treats every rule as an error, and suppression comments are not accepted. See
@@ -65,7 +65,7 @@ class PartnerAuth(CachedTokenAuth):
65
65
 
66
66
  Or `_mint_flow(request)` yields the requests the exchange needs, so they travel through the owning client's own stack
67
67
  (its transport, rate budget, retries and error mapping), and ends by yielding `Minted(token, lifetime)`.
68
- [`examples/orders_client.py`](../examples/orders_client.py) has a complete one. What the flow can count on:
68
+ [`examples/orders_client.py`](https://github.com/kelsayed17/headgate/blob/main/examples/orders_client.py) has a complete one. What the flow can count on:
69
69
 
70
70
  - **Each response arrives read**, so `.content`, `.text` and `.json()` work on it. It is read up to 1 MiB
71
71
  (`headgate.MAX_MINT_RESPONSE_BYTES`, decompressed); a larger one fails the mint with `ResponseTooLargeError`.
@@ -30,13 +30,18 @@ policy = TransportPolicy(cache=cache)
30
30
  - **`policy`** is a hishel `FilterPolicy`. headgate supplies three filters: `PathFragmentFilter(fragments)` (the URL path
31
31
  contains one of them), `MethodFilter(methods)` and `StatusCodeFilter(codes)`. A `FilterPolicy` stores whenever a
32
32
  request filter matches and an allowed status comes back, **even past a `no-store`**, which is the point for a
33
- provider that sends no cache headers, or wrong ones. Store only 200s, so an error body never poisons the cache.
34
- Body-keyed POST caching is hishel's own `FilterPolicy.use_body_key`.
33
+ provider that sends no cache headers, or wrong ones. Body-keyed POST caching is hishel's own
34
+ `FilterPolicy.use_body_key`.
35
+ - **Stored on accept.** A response the policy allows is written to storage only once the call's `expect=` reader has
36
+ accepted it. A call whose reader raises stores nothing, so the cache never serves an answer the client refused:
37
+ a refusal inside a 200 that a `check` catches, an undecodable body, an undeclared status. A declared non-2xx
38
+ (`Expect(...).on(404, ...)`) is stored when the filters allow its status. A `stream()` has no reader, so passing its
39
+ head checks accepts it. A write that fails then is counted as a degradation; the call keeps its answer.
35
40
  - **`ttl`** is the client's default lifetime in seconds; a call's `cache_ttl=` overrides it.
36
41
  - **TTL, not revalidation.** An entry is served until its lifetime ends and is never revalidated with the origin. For
37
42
  revalidation, use [conditional reads](#conditional-reads).
38
43
  - The body is held up to `HttpOptions.max_response_bytes`, and a body past it raises `ResponseTooLargeError` as it would
39
- uncached.
44
+ uncached. A body past the cap is not stored, not even for a `stream()`.
40
45
 
41
46
  ## Partitions
42
47
 
@@ -66,10 +71,12 @@ stored either; the request that fetched it still receives it.
66
71
 
67
72
  hishel has no request coalescing, so an identical GET arriving while the first is still being fetched would miss too.
68
73
  headgate makes it wait and gives it a copy of the first one's response (a hit, a miss or a degraded fetch). Identical
69
- means the same URL, every header, the same timeouts and the same retry policy. A network failure fails every waiting
70
- request from the one attempt, each with its own copy of the error, and each caller's retry layer then retries (and
71
- shares again). Only the request that fetched counts toward the [breaker](breaker.md). A body past the size cap is not
72
- held, so each waiting request fetches its own. POSTs are never shared.
74
+ means the same URL, every header, the same timeouts and the same retry policy; the timeouts compared are the ones the
75
+ call asked for, not the ones its deadline cut, so calls with different time left still share. A timeout that only the
76
+ first request's deadline cut short is not passed on: each waiting request then fetches its own. A network failure fails
77
+ every waiting request from the one attempt, each with its own copy of the error, and each caller's retry layer then
78
+ retries (and shares again). Only the request that fetched counts toward the [breaker](breaker.md). A body past the size
79
+ cap is not held, so each waiting request fetches its own. POSTs are never shared.
73
80
 
74
81
  ## Storage faults
75
82
 
@@ -0,0 +1,50 @@
1
+ ---
2
+ hide:
3
+ - toc
4
+ ---
5
+
6
+ # Compared with others
7
+
8
+ headgate is a base for **HTTP API clients**: it owns the whole call, from building the request to reading the body,
9
+ and handles the concerns every client shares. The libraries people usually reach for each solve one of those concerns,
10
+ for any code. This page says what that difference buys, and when to use them instead.
11
+
12
+ ## At a glance
13
+
14
+ <div class="hg-compare" markdown>
15
+
16
+ | | tenacity | stamina | a hand-rolled httpx client | headgate |
17
+ | --- | --- | --- | --- | --- |
18
+ | Scope | retry any callable | retry any callable (an opinionated tenacity wrapper) | what you write | HTTP API clients |
19
+ | What is retried | the whole decorated call | the whole decorated call | what you write | only the send: below auth and the cache, so a cache hit is never retried and each re-send spends its own rate turn |
20
+ | Which requests are safe to re-send | your predicate | your `on=` | what you write | idempotent methods; a POST or PATCH only when it provably never left, or under an idempotency key stamped once per call |
21
+ | `Retry-After` | write it yourself | write it yourself | write it yourself | waited exactly, up to a cap; past it the call fails at once with `retry_after` |
22
+ | Total deadline | `stop_after_delay` | `timeout=` | write it yourself | one deadline over every attempt, backoff, rate-budget wait and the body read |
23
+ | Retry budget across calls | no | no | write it yourself | yes, so retries cannot multiply an outage |
24
+ | Rate limiting | no | no | add aiolimiter or similar | a shared budget that reads `RateLimit` and `X-RateLimit-*` headers and backs off on pushback |
25
+ | Circuit breaker | no | no | add a breaker library | per origin, thread-safe, charged only for the host's own failures |
26
+ | Caching | no | no | add hishel | hishel, partitioned by credential, never storing credentials, storing only what the call accepted |
27
+ | Credential safety | n/a | n/a | write it yourself | credentials pinned to their origin, an https-to-http redirect refused, an optional SSRF guard |
28
+ | Observability | `before_sleep` and other hooks | instrumentation built in | write it yourself | typed events and an OpenTelemetry observer |
29
+ | Test support | none built in | `set_testing()` | patch what you must | `headgate.testing`: a fake network, instant retries, failure schedules, a test clock |
30
+
31
+ </div>
32
+
33
+ ## When to use something else
34
+
35
+ - **Retrying code that is not an HTTP call** (a database query, a file operation): use stamina, or tenacity when you
36
+ need its full flexibility. headgate's retry lives in its transport stack and retries only HTTP sends.
37
+ - **Python before 3.14**: headgate needs 3.14 for its template-string paths. Use httpx with stamina or tenacity, and
38
+ add rate limiting and caching yourself.
39
+ - **Upstream httpx**: headgate runs on httpx2 and refuses httpx objects, to catch the mix-up early. If your code has to
40
+ stay on httpx, headgate is not for you yet.
41
+ - **A synchronous codebase**: headgate is async only.
42
+ - **One or two calls in a script**: a plain httpx2 call is fine. headgate pays off when a client has several endpoints,
43
+ several consumers, or has to survive a provider's bad day.
44
+
45
+ ## Why httpx2
46
+
47
+ headgate is built and tested on [httpx2](https://pypi.org/project/httpx2/), pydantic's fork of httpx, and on nothing
48
+ else. Its API is httpx's, so moving a client over is mostly an import change: `import httpx2` where you had
49
+ `import httpx`, and the same for the exceptions you catch. headgate refuses an `httpx` object anywhere it takes an httpx2
50
+ one, with a `TypeError` naming the mistake, rather than failing later in a confusing way.
@@ -32,8 +32,15 @@ below act on it as well as the outer timer:
32
32
  - the retry layer starts no attempt or wait with less than `RetryPolicy.min_attempt_time` (0.05 s) left, and raises
33
33
  the last failure with the note `deadline exhausted` instead;
34
34
  - a rate-budget pause that would outlast the deadline raises `RateLimitError` at once rather than waiting;
35
+ - a turn in the rate budget that comes at or after the deadline is not waited for. On the first attempt the call
36
+ raises `ClientTimeoutError` at once, naming it as never sent, with `attempts=0`, `retryable=True` and `retry_after`
37
+ the wait it was refused. On a retry, the call ends with the failure it already has, noted `deadline exhausted`, and a
38
+ hedge is simply not sent;
35
39
  - after a rate-budget wait the send's timeouts are cut again, to what the wait left.
36
40
 
41
+ A deadline that passes before the request is sent (waiting for an in-flight slot, say) says so too:
42
+ `GET item/3: deadline exceeded after 3s, before attempt 1 was sent`, with `attempts` counting only the sends made.
43
+
37
44
  For [`stream()`](streaming.md) the deadline bounds opening the stream (every attempt, backoff and the head checks), not
38
45
  reading it: the caller's pace of iteration decides how long the body takes. Wrap the block in `asyncio.timeout` to
39
46
  bound the whole body.
@@ -47,8 +54,18 @@ number replaces the read timeout only: `HttpOptions(timeout=60.0)` keeps the oth
47
54
  A call overrides them with `timeout=`: a `Timeouts` replaces all four, a number the read timeout alone. A view made with
48
55
  `with_options(timeout=...)` does the same for its calls. Every value must be positive.
49
56
 
57
+ A timeout must fit the deadline it runs under, or it could never take effect: each attempt's timeouts are cut to the
58
+ time left. So a timeout longer than that deadline is refused with `ValueError` where it is set: on the client (against
59
+ the policy's deadline), on a view, or on a call. A bare number is checked as the read timeout, and a `Timeouts` by the
60
+ phases set away from their defaults. Shortening the deadline instead is always allowed; cutting the timeouts is what a
61
+ short deadline is for. To give a slow endpoint a long read timeout, raise the deadline with it:
62
+
63
+ ```python
64
+ reports = client.with_options(timeout=300.0, deadline=600.0)
65
+ ```
66
+
50
67
  ## Choosing values
51
68
 
52
- - Keep `read` below the deadline, or a single slow response takes the whole call and leaves no time to retry it.
69
+ - Keep `read` well below the deadline, or a single slow response takes the whole call and leaves no time to retry it.
53
70
  - A long export or report endpoint wants a `Route` with a longer deadline rather than a client-wide one.
54
71
  - `deadline=None` exists for calls that legitimately run unbounded (a long poll); prefer a generous number.
@@ -0,0 +1,158 @@
1
+ ---
2
+ title: An async API-client base for Python
3
+ hide:
4
+ - toc
5
+ ---
6
+
7
+ <div class="hg-hero" markdown>
8
+
9
+ ![headgate logo](assets/logo.png)
10
+
11
+ <div markdown>
12
+
13
+ # headgate
14
+
15
+ **An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
16
+ `AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour.
17
+ Each endpoint method stays one line.
18
+
19
+ [Get started](#install){ .md-button .md-button--primary }
20
+ [How it compares](comparison.md){ .md-button }
21
+ [:fontawesome-brands-github: GitHub](https://github.com/kelsayed17/headgate){ .md-button }
22
+
23
+ </div>
24
+
25
+ </div>
26
+
27
+ <div class="grid cards" markdown>
28
+
29
+ - :lucide-rotate-cw:{ .lg .hg-icon } **Retries that are safe**
30
+
31
+ ---
32
+
33
+ Idempotent methods only, or a POST under an idempotency key. `Retry-After` is waited exactly, and a budget stops
34
+ retries multiplying an outage.
35
+
36
+ [:lucide-arrow-right: Retries](retries.md)
37
+
38
+ - :lucide-gauge:{ .lg .hg-icon } **A rate budget that listens**
39
+
40
+ ---
41
+
42
+ One budget shared by every call, paused by `RateLimit` and `X-RateLimit-*` headers and slowed by pushback.
43
+
44
+ [:lucide-arrow-right: Rate budgets](rate-budgets.md)
45
+
46
+ - :lucide-timer:{ .lg .hg-icon } **Deadlines and a breaker**
47
+
48
+ ---
49
+
50
+ One deadline bounds every attempt, wait and read. A per-origin circuit breaker stops calls to a host that is down.
51
+
52
+ [:lucide-arrow-right: Deadlines](deadlines.md) · [Circuit breaker](breaker.md)
53
+
54
+ - :lucide-key-round:{ .lg .hg-icon } **Credentials that stay home**
55
+
56
+ ---
57
+
58
+ Credentials are pinned to their origin, an https-to-http redirect is refused, and an SSRF guard is one option away.
59
+
60
+ [:lucide-arrow-right: Security](security.md)
61
+
62
+ - :lucide-database-zap:{ .lg .hg-icon } **A cache that keeps no secrets**
63
+
64
+ ---
65
+
66
+ hishel's cache, partitioned by credential, never storing one, and storing only what your reader accepted.
67
+
68
+ [:lucide-arrow-right: Caching](caching.md)
69
+
70
+ - :lucide-activity:{ .lg .hg-icon } **Errors and events that explain**
71
+
72
+ ---
73
+
74
+ Typed errors that say what happened and whether to retry, typed events, and an OpenTelemetry observer.
75
+
76
+ [:lucide-arrow-right: Errors](errors.md) · [Observability](observability.md)
77
+
78
+ </div>
79
+
80
+ ## Install
81
+
82
+ === "pip"
83
+
84
+ ```bash
85
+ pip install headgate
86
+ ```
87
+
88
+ === "uv"
89
+
90
+ ```bash
91
+ uv add headgate
92
+ ```
93
+
94
+ headgate needs **Python 3.14** or newer: endpoint paths are [template strings](https://peps.python.org/pep-0750/), so
95
+ each value is escaped into its own path segment by default. It runs on **httpx2**, pydantic's fork of httpx; see
96
+ [why httpx2](comparison.md#why-httpx2).
97
+
98
+ ## A real client
99
+
100
+ A client for a few GitHub REST endpoints. The full version, with its tests, is
101
+ [`examples/github_client.py`](https://github.com/kelsayed17/headgate/blob/main/examples/github_client.py).
102
+
103
+ ```python
104
+ from http import HTTPStatus
105
+
106
+ import msgspec
107
+
108
+ from headgate import AsyncBaseClient, Expect, HttpOptions, LinkHeader, Rate, RateBudget, StaticHeaderAuth, nothing
109
+ from headgate.msgspec import decoder
110
+
111
+
112
+ class Repository(msgspec.Struct, frozen=True):
113
+ full_name: str
114
+ stargazers_count: int
115
+
116
+
117
+ class Issue(msgspec.Struct, frozen=True):
118
+ number: int
119
+ title: str
120
+
121
+
122
+ REPOSITORY_OR_NONE = Expect(decoder(Repository)).on(HTTPStatus.NOT_FOUND, nothing)
123
+ ISSUE_PAGES = LinkHeader(items=lambda page: page) # each page is a JSON list of issues
124
+
125
+
126
+ class GitHub(AsyncBaseClient):
127
+ def __init__(self, token: str) -> None:
128
+ super().__init__(
129
+ base_url="https://api.github.com",
130
+ rate=RateBudget(Rate.per_second(10), max_in_flight=50),
131
+ auth=StaticHeaderAuth({"Authorization": f"Bearer {token}"}),
132
+ http=HttpOptions(headers={"Accept": "application/vnd.github+json"}),
133
+ )
134
+
135
+ async def repository(self, owner: str, name: str) -> Repository | None:
136
+ return await self.get(t"repos/{owner}/{name}", expect=REPOSITORY_OR_NONE)
137
+
138
+ def open_issues(self, owner: str, name: str):
139
+ return self.paginate(t"repos/{owner}/{name}/issues", expect=decoder(list[Issue]), pages=ISSUE_PAGES)
140
+ ```
141
+
142
+ ```python
143
+ async with GitHub(token) as github:
144
+ repository = await github.repository("python", "cpython")
145
+ async for issue in github.open_issues("python", "cpython"):
146
+ print(issue.number, issue.title)
147
+ ```
148
+
149
+ Retries, pacing under GitHub's limits, pausing when its hourly limit is spent, following `Link` headers, and keeping
150
+ the token on `api.github.com` all come from the base class.
151
+
152
+ ## Where to next
153
+
154
+ - [Compared with others](comparison.md): what headgate does that tenacity, stamina or a hand-rolled httpx client
155
+ does not, and when to pick those instead.
156
+ - [Configuration](configuration.md): every option, and where it lives.
157
+ - [Declared responses](responses.md) and [errors](errors.md): what an endpoint answers, and what it raises.
158
+ - [Testing a client](testing.md): fake networks, instant retries and failure schedules, with nothing private patched.
@@ -27,6 +27,31 @@ Nothing is fetched until iteration starts, no page is kept once the iteration mo
27
27
  nothing more, and iterating again starts again from the first page. A page that leads back to a request already made
28
28
  (a cursor or next URL that repeats) raises `ClientError` instead of looping forever.
29
29
 
30
+ ## A page budget
31
+
32
+ A walk ends at its last page, but a server bug, an unordered dataset or a too-broad filter can keep naming new pages
33
+ for as long as you keep asking. `limit=PageLimit(max_pages)` bounds the walk:
34
+
35
+ ```python
36
+ from headgate import PageLimit, PageLimitError
37
+
38
+ orders = client.paginate("orders", expect=decoder(Page), pages=ORDER_PAGES, limit=PageLimit(50))
39
+ try:
40
+ async for order in orders:
41
+ ...
42
+ except PageLimitError as exc:
43
+ print(f"stopped after {exc.pages} pages: the result is incomplete")
44
+ ```
45
+
46
+ - **`on_limit="raise"`** (the default) raises `PageLimitError`, a `ClientError`, once the budget is spent with pages
47
+ still to come. The pages within the budget were already yielded; `exc.pages` says how many. A caller cannot mistake
48
+ a cut-short walk for a complete one.
49
+ - **`on_limit="stop"`** ends the walk at the budget instead, for a caller that wants only the first pages:
50
+ `PageLimit(5, on_limit="stop")`.
51
+ - A walk whose last page falls within the budget, or exactly on it, is unaffected.
52
+
53
+ Set the budget where the client calls `paginate`, so every caller of that endpoint gets it.
54
+
30
55
  `paginate` sends GETs, so it refuses body options (`json`, `data`, `content`, `files`) with `ValueError`. Its `params=`
31
56
  are the first page's query, and a pager's parameter is added to them. `expect=` may be an `Expect`: a page can be a
32
57
  declared answer like any other.
@@ -35,10 +60,14 @@ declared answer like any other.
35
60
 
36
61
  | Strategy | The next page is | Last page |
37
62
  | --- | --- | --- |
38
- | `Cursor(items=, next=, param=)` | the same request with `param` set to the cursor `next(page)` returns | `next` returns `None` or `""` |
63
+ | `Cursor(items=, next=, param=)` | the same request with `param` set to the cursor `next(page)` returns | `next` returns `None` or `""`, or a page with no items |
39
64
  | `Offset(items=, limit=, param="offset", start=0)` | the same request with `param` advanced by the items this page held | a page with fewer than `limit` items |
40
- | `LinkHeader(items=)` | the target of the response's `Link: <...>; rel="next"` (RFC 8288) | no `rel="next"` link |
41
- | `NextUrl(items=, next=)` | the URL `next(page)` returns, absolute or relative | `next` returns `None` or `""` |
65
+ | `LinkHeader(items=)` | the target of the response's `Link: <...>; rel="next"` (RFC 8288) | no `rel="next"` link, or a page with no items |
66
+ | `NextUrl(items=, next=)` | the URL `next(page)` returns, absolute or relative | `next` returns `None` or `""`, or a page with no items |
67
+
68
+ A page with no items ends a `Cursor`, `LinkHeader` or `NextUrl` walk even when it links to another: a link that
69
+ advances while returning nothing makes no progress, and following it spends the rate budget for nothing. For an
70
+ endpoint that sends empty pages mid-walk, pass `stop_on_empty=False`.
42
71
 
43
72
  `Offset` sends only the offset; the page size goes in `params=`, under whatever name the endpoint uses (`limit`,
44
73
  `per_page`, `page_size`). A next URL from the wire (`LinkHeader`, `NextUrl`) is resolved against the URL that answered
@@ -28,7 +28,8 @@ under a `threading.Lock` and nothing in it is bound to a loop.
28
28
 
29
29
  - **Pacing** is GCRA, the virtual-scheduling form of a leaky bucket. Up to `rate.requests` requests (at least one) go
30
30
  out back to back; after that they are `period / requests` seconds apart. Waiters are served in arrival order, and a
31
- waiter cancelled in the queue (its deadline passed, say) gives its turn back when nobody reserved after it.
31
+ waiter cancelled in the queue gives its turn back when nobody reserved after it. A call whose turn comes at or after
32
+ its deadline does not queue: it gives the turn back and the attempt is never sent (see [deadlines](deadlines.md)).
32
33
  - **`max_in_flight`** caps how many requests are open at once. A request holds its slot from the send until its
33
34
  response body is **closed**, not until the head arrives, since the server is still busy with a body that streams.
34
35
  Every path that drops a response closes it, so a slot cannot leak.
@@ -0,0 +1,91 @@
1
+ /* headgate's colours, taken from the logo: deep navy, a working blue and a bright cyan. */
2
+ :root {
3
+ --hg-navy: #011740;
4
+ --hg-blue: #0a82e0;
5
+ --hg-cyan: #1fd6f5;
6
+ }
7
+
8
+ [data-md-color-scheme="default"] {
9
+ --md-primary-fg-color: var(--hg-navy);
10
+ --md-primary-fg-color--light: #0b2a63;
11
+ --md-primary-fg-color--dark: #000d26;
12
+ --md-accent-fg-color: var(--hg-blue);
13
+ --md-typeset-a-color: var(--hg-blue);
14
+ }
15
+
16
+ [data-md-color-scheme="slate"] {
17
+ --md-primary-fg-color: var(--hg-cyan);
18
+ --md-primary-fg-color--light: #6ae4fa;
19
+ --md-primary-fg-color--dark: #0fb3cf;
20
+ /* Text on the cyan: navy, since white on a light cyan is hard to read. */
21
+ --md-primary-bg-color: var(--hg-navy);
22
+ --md-primary-bg-color--light: rgba(1, 23, 64, 0.7);
23
+ --md-accent-fg-color: var(--hg-cyan);
24
+ --md-typeset-a-color: var(--hg-cyan);
25
+ }
26
+
27
+ /* The landing page's hero: the logo, the name, one sentence and the ways in. */
28
+ .hg-hero {
29
+ display: grid;
30
+ grid-template-columns: 9rem 1fr;
31
+ gap: 1.5rem 2rem;
32
+ align-items: center;
33
+ margin: 1rem 0 2.5rem;
34
+ }
35
+
36
+ .hg-hero img {
37
+ width: 9rem;
38
+ height: 9rem;
39
+ border-radius: 1.6rem;
40
+ box-shadow: 0 0.6rem 2rem rgba(10, 130, 224, 0.35);
41
+ }
42
+
43
+ .md-typeset .hg-hero h1 {
44
+ margin: 0 0 0.4rem;
45
+ font-weight: 800;
46
+ letter-spacing: -0.02em;
47
+ }
48
+
49
+ .hg-hero p {
50
+ margin: 0 0 1rem;
51
+ font-size: 1.05rem;
52
+ }
53
+
54
+ .md-typeset .hg-hero .md-button {
55
+ margin: 0.2rem 0.4rem 0.2rem 0;
56
+ }
57
+
58
+ @media screen and (max-width: 44.9375em) {
59
+ .hg-hero {
60
+ grid-template-columns: 1fr;
61
+ justify-items: center;
62
+ text-align: center;
63
+ }
64
+
65
+ .hg-hero img {
66
+ width: 6rem;
67
+ height: 6rem;
68
+ border-radius: 1.1rem;
69
+ }
70
+ }
71
+
72
+ /* Feature cards: a coloured icon above each title. */
73
+ .md-typeset .grid.cards .hg-icon {
74
+ color: var(--md-accent-fg-color);
75
+ }
76
+
77
+ /* Wide tables read better a little smaller, with each row named in bold. */
78
+ .md-typeset table:not([class]) {
79
+ font-size: 0.7rem;
80
+ }
81
+
82
+ .md-typeset table:not([class]) td:first-child {
83
+ min-width: 8rem;
84
+ font-weight: 600;
85
+ }
86
+
87
+ /* The comparison's last column is headgate's: tinted, so it reads as the answer. */
88
+ .hg-compare table td:last-child,
89
+ .hg-compare table th:last-child {
90
+ background-color: color-mix(in srgb, var(--md-accent-fg-color) 9%, transparent);
91
+ }
@@ -4,7 +4,7 @@
4
4
  with nothing private patched. Two seams make that possible:
5
5
 
6
6
  - **the network** is `HttpOptions(transport=...)`. Have the client accept a transport and pass it through, as the
7
- [examples](../examples/) do: `CatalogClient(api_key, transport=fake)`;
7
+ [examples](https://github.com/kelsayed17/headgate/tree/main/examples) do: `CatalogClient(api_key, transport=fake)`;
8
8
  - **time and breaker state** are read when a client is **constructed**, from context variables the kit's context
9
9
  managers set. Build the client inside them; entering one around a client that already exists changes nothing.
10
10