headgate 0.2.0__tar.gz → 0.3.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 (255) hide show
  1. {headgate-0.2.0 → headgate-0.3.0}/CHANGELOG.md +33 -2
  2. {headgate-0.2.0 → headgate-0.3.0}/PKG-INFO +5 -1
  3. {headgate-0.2.0 → headgate-0.3.0}/README.md +4 -0
  4. {headgate-0.2.0 → headgate-0.3.0}/docs/caching.md +14 -7
  5. {headgate-0.2.0 → headgate-0.3.0}/docs/deadlines.md +18 -1
  6. {headgate-0.2.0 → headgate-0.3.0}/docs/rate-budgets.md +2 -1
  7. {headgate-0.2.0 → headgate-0.3.0}/pyproject.toml +2 -2
  8. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_client.py +34 -8
  9. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/__init__.py +2 -1
  10. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/_context.py +40 -1
  11. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/_seams.py +17 -1
  12. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_options.py +26 -0
  13. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/cache/_transport.py +109 -32
  14. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/__init__.py +2 -0
  15. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/_budget.py +7 -1
  16. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/_transport.py +1 -0
  17. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/retry/_transport.py +37 -9
  18. {headgate-0.2.0 → headgate-0.3.0}/tests/auth/test_mint_failures.py +3 -2
  19. {headgate-0.2.0 → headgate-0.3.0}/tests/breaker/test_breaker_charging.py +13 -3
  20. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/_helpers.py +8 -1
  21. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_credentials.py +3 -3
  22. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_fallback.py +80 -56
  23. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_held_bodies.py +2 -2
  24. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_shared_downloads.py +62 -0
  25. headgate-0.3.0/tests/cache/test_store_on_accept.py +146 -0
  26. headgate-0.3.0/tests/client/test_timeouts_fit_deadline.py +100 -0
  27. {headgate-0.2.0 → headgate-0.3.0}/tests/core/test_context.py +27 -0
  28. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_deadline.py +50 -0
  29. {headgate-0.2.0 → headgate-0.3.0}/tests/package/__snapshots__/test_api_surface.ambr +2 -1
  30. {headgate-0.2.0 → headgate-0.3.0}/tests/ratelimit/test_budget.py +25 -1
  31. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_parking_and_hedging.py +81 -1
  32. {headgate-0.2.0 → headgate-0.3.0}/.gitignore +0 -0
  33. {headgate-0.2.0 → headgate-0.3.0}/LICENSE +0 -0
  34. {headgate-0.2.0 → headgate-0.3.0}/docs/auth.md +0 -0
  35. {headgate-0.2.0 → headgate-0.3.0}/docs/breaker.md +0 -0
  36. {headgate-0.2.0 → headgate-0.3.0}/docs/configuration.md +0 -0
  37. {headgate-0.2.0 → headgate-0.3.0}/docs/design.md +0 -0
  38. {headgate-0.2.0 → headgate-0.3.0}/docs/errors.md +0 -0
  39. {headgate-0.2.0 → headgate-0.3.0}/docs/mutation-testing.md +0 -0
  40. {headgate-0.2.0 → headgate-0.3.0}/docs/observability.md +0 -0
  41. {headgate-0.2.0 → headgate-0.3.0}/docs/pagination.md +0 -0
  42. {headgate-0.2.0 → headgate-0.3.0}/docs/responses.md +0 -0
  43. {headgate-0.2.0 → headgate-0.3.0}/docs/retries.md +0 -0
  44. {headgate-0.2.0 → headgate-0.3.0}/docs/security.md +0 -0
  45. {headgate-0.2.0 → headgate-0.3.0}/docs/streaming.md +0 -0
  46. {headgate-0.2.0 → headgate-0.3.0}/docs/testing.md +0 -0
  47. {headgate-0.2.0 → headgate-0.3.0}/examples/__init__.py +0 -0
  48. {headgate-0.2.0 → headgate-0.3.0}/examples/boundary.py +0 -0
  49. {headgate-0.2.0 → headgate-0.3.0}/examples/orders_client.py +0 -0
  50. {headgate-0.2.0 → headgate-0.3.0}/examples/quickstart.py +0 -0
  51. {headgate-0.2.0 → headgate-0.3.0}/examples/series_client.py +0 -0
  52. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/__init__.py +0 -0
  53. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_attempts.py +0 -0
  54. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_auth.py +0 -0
  55. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_body.py +0 -0
  56. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_codec.py +0 -0
  57. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/_events.py +0 -0
  58. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/_origin.py +0 -0
  59. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/_retry_after.py +0 -0
  60. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/_switch.py +0 -0
  61. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_core/_time.py +0 -0
  62. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_curl.py +0 -0
  63. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_deprecation.py +0 -0
  64. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_failures.py +0 -0
  65. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_middleware.py +0 -0
  66. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_redirects.py +0 -0
  67. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_request_body.py +0 -0
  68. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_responses.py +0 -0
  69. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_stack.py +0 -0
  70. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_url.py +0 -0
  71. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_user_agent.py +0 -0
  72. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/_view.py +0 -0
  73. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/breaker/__init__.py +0 -0
  74. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/breaker/_breaker.py +0 -0
  75. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/breaker/_policy.py +0 -0
  76. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/breaker/_registry.py +0 -0
  77. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/breaker/_scope.py +0 -0
  78. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/cache/__init__.py +0 -0
  79. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/cache/_conditional.py +0 -0
  80. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/cache/_credentials.py +0 -0
  81. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/cache/_policy.py +0 -0
  82. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/curl.py +0 -0
  83. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/errors.py +0 -0
  84. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/events.py +0 -0
  85. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/health.py +0 -0
  86. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/msgspec.py +0 -0
  87. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/otel.py +0 -0
  88. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/pagination/__init__.py +0 -0
  89. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/pagination/_link.py +0 -0
  90. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/pagination/_pager.py +0 -0
  91. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/pagination/_paginated.py +0 -0
  92. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/policy.py +0 -0
  93. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/py.typed +0 -0
  94. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/pydantic.py +0 -0
  95. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/_budgets.py +0 -0
  96. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/_feedback.py +0 -0
  97. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/_fields.py +0 -0
  98. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/_inflight.py +0 -0
  99. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/ratelimit/_rate.py +0 -0
  100. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/retry/__init__.py +0 -0
  101. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/retry/_budget.py +0 -0
  102. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/retry/_classify.py +0 -0
  103. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/retry/_policy.py +0 -0
  104. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/retry/_tracking.py +0 -0
  105. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/security/__init__.py +0 -0
  106. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/security/_addresses.py +0 -0
  107. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/security/_credentials.py +0 -0
  108. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/security/_downgrade.py +0 -0
  109. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/security/_transport.py +0 -0
  110. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/streaming/__init__.py +0 -0
  111. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/streaming/_stream.py +0 -0
  112. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/testing/__init__.py +0 -0
  113. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/testing/_breakers.py +0 -0
  114. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/testing/_recording.py +0 -0
  115. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/testing/_time.py +0 -0
  116. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/testing/_transport.py +0 -0
  117. {headgate-0.2.0 → headgate-0.3.0}/src/headgate/testing/faults.py +0 -0
  118. {headgate-0.2.0 → headgate-0.3.0}/tests/__init__.py +0 -0
  119. {headgate-0.2.0 → headgate-0.3.0}/tests/auth/__init__.py +0 -0
  120. {headgate-0.2.0 → headgate-0.3.0}/tests/auth/test_cached_token_auth.py +0 -0
  121. {headgate-0.2.0 → headgate-0.3.0}/tests/auth/test_credential_stampers.py +0 -0
  122. {headgate-0.2.0 → headgate-0.3.0}/tests/auth/test_flows_are_uniform.py +0 -0
  123. {headgate-0.2.0 → headgate-0.3.0}/tests/auth/test_header_and_query_auth.py +0 -0
  124. {headgate-0.2.0 → headgate-0.3.0}/tests/breaker/__init__.py +0 -0
  125. {headgate-0.2.0 → headgate-0.3.0}/tests/breaker/test_breaker.py +0 -0
  126. {headgate-0.2.0 → headgate-0.3.0}/tests/breaker/test_breaker_integration.py +0 -0
  127. {headgate-0.2.0 → headgate-0.3.0}/tests/breaker/test_breaker_keying.py +0 -0
  128. {headgate-0.2.0 → headgate-0.3.0}/tests/breaker/test_breaker_state_machine.py +0 -0
  129. {headgate-0.2.0 → headgate-0.3.0}/tests/breaker/test_registry.py +0 -0
  130. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/__init__.py +0 -0
  131. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_conditional.py +0 -0
  132. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_events.py +0 -0
  133. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_extension_passthrough.py +0 -0
  134. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_filters.py +0 -0
  135. {headgate-0.2.0 → headgate-0.3.0}/tests/cache/test_integration.py +0 -0
  136. {headgate-0.2.0 → headgate-0.3.0}/tests/client/__init__.py +0 -0
  137. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_call_options.py +0 -0
  138. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_client.py +0 -0
  139. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_deadline.py +0 -0
  140. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_error_details.py +0 -0
  141. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_error_messages.py +0 -0
  142. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_events.py +0 -0
  143. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_failure_policy.py +0 -0
  144. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_invalid_url.py +0 -0
  145. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_middleware.py +0 -0
  146. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_options.py +0 -0
  147. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_policy.py +0 -0
  148. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_redirect_identity.py +0 -0
  149. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_response_size_cap.py +0 -0
  150. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_retry_opt_out.py +0 -0
  151. {headgate-0.2.0 → headgate-0.3.0}/tests/client/test_verbs.py +0 -0
  152. {headgate-0.2.0 → headgate-0.3.0}/tests/conftest.py +0 -0
  153. {headgate-0.2.0 → headgate-0.3.0}/tests/core/__init__.py +0 -0
  154. {headgate-0.2.0 → headgate-0.3.0}/tests/core/test_events.py +0 -0
  155. {headgate-0.2.0 → headgate-0.3.0}/tests/core/test_origin.py +0 -0
  156. {headgate-0.2.0 → headgate-0.3.0}/tests/core/test_retry_after.py +0 -0
  157. {headgate-0.2.0 → headgate-0.3.0}/tests/decoding/__init__.py +0 -0
  158. {headgate-0.2.0 → headgate-0.3.0}/tests/decoding/test_errors.py +0 -0
  159. {headgate-0.2.0 → headgate-0.3.0}/tests/decoding/test_event_stream.py +0 -0
  160. {headgate-0.2.0 → headgate-0.3.0}/tests/decoding/test_msgspec.py +0 -0
  161. {headgate-0.2.0 → headgate-0.3.0}/tests/decoding/test_pydantic.py +0 -0
  162. {headgate-0.2.0 → headgate-0.3.0}/tests/decoding/test_responses.py +0 -0
  163. {headgate-0.2.0 → headgate-0.3.0}/tests/errors/__init__.py +0 -0
  164. {headgate-0.2.0 → headgate-0.3.0}/tests/errors/__snapshots__/test_error_messages_snapshot.ambr +0 -0
  165. {headgate-0.2.0 → headgate-0.3.0}/tests/errors/test_error_messages_snapshot.py +0 -0
  166. {headgate-0.2.0 → headgate-0.3.0}/tests/examples/__init__.py +0 -0
  167. {headgate-0.2.0 → headgate-0.3.0}/tests/examples/test_docs.py +0 -0
  168. {headgate-0.2.0 → headgate-0.3.0}/tests/examples/test_orders_client.py +0 -0
  169. {headgate-0.2.0 → headgate-0.3.0}/tests/examples/test_quickstart.py +0 -0
  170. {headgate-0.2.0 → headgate-0.3.0}/tests/examples/test_series_client.py +0 -0
  171. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/__init__.py +0 -0
  172. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/_support.py +0 -0
  173. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_breaker_gate.py +0 -0
  174. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_cache_and_auths.py +0 -0
  175. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_destinations.py +0 -0
  176. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_events.py +0 -0
  177. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_hedging.py +0 -0
  178. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_pagination_credentials.py +0 -0
  179. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_replayable_body.py +0 -0
  180. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_retry_and_rate.py +0 -0
  181. {headgate-0.2.0 → headgate-0.3.0}/tests/integration/test_views.py +0 -0
  182. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/__init__.py +0 -0
  183. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/__snapshots__/test_snapshots.ambr +0 -0
  184. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/_support.py +0 -0
  185. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/test_accept.py +0 -0
  186. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/test_deprecation.py +0 -0
  187. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/test_otel.py +0 -0
  188. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/test_otel_isolation.py +0 -0
  189. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/test_snapshots.py +0 -0
  190. {headgate-0.2.0 → headgate-0.3.0}/tests/observe/test_with_options.py +0 -0
  191. {headgate-0.2.0 → headgate-0.3.0}/tests/package/__init__.py +0 -0
  192. {headgate-0.2.0 → headgate-0.3.0}/tests/package/test_api_surface.py +0 -0
  193. {headgate-0.2.0 → headgate-0.3.0}/tests/package/test_boundaries.py +0 -0
  194. {headgate-0.2.0 → headgate-0.3.0}/tests/package/test_contracts.py +0 -0
  195. {headgate-0.2.0 → headgate-0.3.0}/tests/package/test_import_isolation.py +0 -0
  196. {headgate-0.2.0 → headgate-0.3.0}/tests/package/test_metadata.py +0 -0
  197. {headgate-0.2.0 → headgate-0.3.0}/tests/pagination/__init__.py +0 -0
  198. {headgate-0.2.0 → headgate-0.3.0}/tests/pagination/test_link_header.py +0 -0
  199. {headgate-0.2.0 → headgate-0.3.0}/tests/pagination/test_paginate.py +0 -0
  200. {headgate-0.2.0 → headgate-0.3.0}/tests/performance/__init__.py +0 -0
  201. {headgate-0.2.0 → headgate-0.3.0}/tests/performance/test_memory.py +0 -0
  202. {headgate-0.2.0 → headgate-0.3.0}/tests/properties/__init__.py +0 -0
  203. {headgate-0.2.0 → headgate-0.3.0}/tests/properties/test_policy_properties.py +0 -0
  204. {headgate-0.2.0 → headgate-0.3.0}/tests/properties/test_url_properties.py +0 -0
  205. {headgate-0.2.0 → headgate-0.3.0}/tests/ratelimit/__init__.py +0 -0
  206. {headgate-0.2.0 → headgate-0.3.0}/tests/ratelimit/test_client_feedback.py +0 -0
  207. {headgate-0.2.0 → headgate-0.3.0}/tests/ratelimit/test_feedback.py +0 -0
  208. {headgate-0.2.0 → headgate-0.3.0}/tests/ratelimit/test_in_flight.py +0 -0
  209. {headgate-0.2.0 → headgate-0.3.0}/tests/ratelimit/test_rate.py +0 -0
  210. {headgate-0.2.0 → headgate-0.3.0}/tests/ratelimit/test_rate_limit_transport.py +0 -0
  211. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/__init__.py +0 -0
  212. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_budget_and_classify.py +0 -0
  213. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_client_retries.py +0 -0
  214. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_faults.py +0 -0
  215. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_policy.py +0 -0
  216. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_properties.py +0 -0
  217. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_tracking.py +0 -0
  218. {headgate-0.2.0 → headgate-0.3.0}/tests/retry/test_transport.py +0 -0
  219. {headgate-0.2.0 → headgate-0.3.0}/tests/security/__init__.py +0 -0
  220. {headgate-0.2.0 → headgate-0.3.0}/tests/security/_servers.py +0 -0
  221. {headgate-0.2.0 → headgate-0.3.0}/tests/security/_web.py +0 -0
  222. {headgate-0.2.0 → headgate-0.3.0}/tests/security/certs/ca.pem +0 -0
  223. {headgate-0.2.0 → headgate-0.3.0}/tests/security/certs/server.pem +0 -0
  224. {headgate-0.2.0 → headgate-0.3.0}/tests/security/test_addresses.py +0 -0
  225. {headgate-0.2.0 → headgate-0.3.0}/tests/security/test_credential_origins.py +0 -0
  226. {headgate-0.2.0 → headgate-0.3.0}/tests/security/test_origin_properties.py +0 -0
  227. {headgate-0.2.0 → headgate-0.3.0}/tests/security/test_pinned_transport.py +0 -0
  228. {headgate-0.2.0 → headgate-0.3.0}/tests/security/test_redirect_hardening.py +0 -0
  229. {headgate-0.2.0 → headgate-0.3.0}/tests/security/test_ssrf_guard.py +0 -0
  230. {headgate-0.2.0 → headgate-0.3.0}/tests/streaming/__init__.py +0 -0
  231. {headgate-0.2.0 → headgate-0.3.0}/tests/streaming/test_request_bodies.py +0 -0
  232. {headgate-0.2.0 → headgate-0.3.0}/tests/streaming/test_stream.py +0 -0
  233. {headgate-0.2.0 → headgate-0.3.0}/tests/support/__init__.py +0 -0
  234. {headgate-0.2.0 → headgate-0.3.0}/tests/support/breakers.py +0 -0
  235. {headgate-0.2.0 → headgate-0.3.0}/tests/support/budgets.py +0 -0
  236. {headgate-0.2.0 → headgate-0.3.0}/tests/support/clients.py +0 -0
  237. {headgate-0.2.0 → headgate-0.3.0}/tests/support/events.py +0 -0
  238. {headgate-0.2.0 → headgate-0.3.0}/tests/support/responses.py +0 -0
  239. {headgate-0.2.0 → headgate-0.3.0}/tests/support/tokens.py +0 -0
  240. {headgate-0.2.0 → headgate-0.3.0}/tests/support/wire.py +0 -0
  241. {headgate-0.2.0 → headgate-0.3.0}/tests/testing/__init__.py +0 -0
  242. {headgate-0.2.0 → headgate-0.3.0}/tests/testing/test_breakers.py +0 -0
  243. {headgate-0.2.0 → headgate-0.3.0}/tests/testing/test_cassettes.py +0 -0
  244. {headgate-0.2.0 → headgate-0.3.0}/tests/testing/test_fake_transport.py +0 -0
  245. {headgate-0.2.0 → headgate-0.3.0}/tests/testing/test_recording.py +0 -0
  246. {headgate-0.2.0 → headgate-0.3.0}/tests/testing/test_time.py +0 -0
  247. {headgate-0.2.0 → headgate-0.3.0}/tests/transport/__init__.py +0 -0
  248. {headgate-0.2.0 → headgate-0.3.0}/tests/transport/test_curl.py +0 -0
  249. {headgate-0.2.0 → headgate-0.3.0}/tests/transport/test_health.py +0 -0
  250. {headgate-0.2.0 → headgate-0.3.0}/tests/urls/__init__.py +0 -0
  251. {headgate-0.2.0 → headgate-0.3.0}/tests/urls/test_empty_segment.py +0 -0
  252. {headgate-0.2.0 → headgate-0.3.0}/tests/urls/test_path_escaping.py +0 -0
  253. {headgate-0.2.0 → headgate-0.3.0}/tests/urls/test_route_template.py +0 -0
  254. {headgate-0.2.0 → headgate-0.3.0}/tests/urls/test_sanitize_url.py +0 -0
  255. {headgate-0.2.0 → headgate-0.3.0}/tests/urls/test_secret_query_params.py +0 -0
@@ -6,6 +6,36 @@ change the public API; such changes are listed under **Changed** and **Removed**
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-26
10
+
11
+ Fixes four bugs where a call's deadline, its timeouts, the rate budget and the cache disagreed. One of them makes a
12
+ configuration that could never work an error, so this is a minor release: see **Changed**.
13
+
14
+ ### Added
15
+
16
+ - `headgate.ratelimit.BudgetBacklogError`, raised by `RateBudget.wait` when a call's paced turn comes after its deadline.
17
+
18
+ ### Changed
19
+
20
+ - A timeout longer than the deadline it runs under is refused with `ValueError` on the client, a view or a call, since
21
+ it could never take effect ([#6](https://github.com/kelsayed17/headgate/issues/6)). Raise the deadline with it, e.g.
22
+ `with_options(timeout=300.0, deadline=600.0)`.
23
+ - A body past `max_response_bytes` is no longer stored in the cache, since it is not held to be written on accept.
24
+
25
+ ### Fixed
26
+
27
+ - A cached miss is stored only once the call's reader accepts it, so a refusal inside a 200, an undecodable body or an
28
+ undeclared status is no longer served from the cache for the entry's lifetime
29
+ ([#9](https://github.com/kelsayed17/headgate/issues/9)). A `stream()` is accepted when its head checks pass.
30
+ - Identical concurrent GETs share one cached download again when their timeouts are longer than the time their deadline
31
+ leaves; the sharing key now uses the timeouts the call asked for, not the deadline-cut ones
32
+ ([#4](https://github.com/kelsayed17/headgate/issues/4)).
33
+ - A call whose rate-budget turn comes after its deadline fails at once with `ClientTimeoutError` saying it was never
34
+ sent, with `retryable=True` and `retry_after`, instead of waiting and failing like a slow upstream; a retry that
35
+ cannot be sent in time ends the call with the failure it already has, and such a hedge is not sent. A deadline that
36
+ passes before an attempt is sent says so, and `attempts` counts only the sends made
37
+ ([#5](https://github.com/kelsayed17/headgate/issues/5)).
38
+
9
39
  ## [0.2.0] - 2026-10-25
10
40
 
11
41
  The first release published to PyPI. It reshapes the client around option objects and rebuilds every policy
@@ -185,6 +215,7 @@ Python 3.14.
185
215
  - The `headgate.msgspec` and `headgate.pydantic` codec flavors, the optional `AsyncCurlTransport` (`headgate[curl]`),
186
216
  `headgate.health` snapshots and `headgate.policy.policy_disabled()`.
187
217
 
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
218
+ [Unreleased]: https://github.com/kelsayed17/headgate/compare/v0.3.0...HEAD
219
+ [0.3.0]: https://github.com/kelsayed17/headgate/compare/159a3c2...v0.3.0
220
+ [0.2.0]: https://github.com/kelsayed17/headgate/compare/f319ad8...159a3c2
190
221
  [0.1.0]: https://github.com/kelsayed17/headgate/commit/f319ad8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: headgate
3
- Version: 0.2.0
3
+ Version: 0.3.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
6
  Project-URL: Documentation, https://github.com/kelsayed17/headgate/tree/main/docs
@@ -37,6 +37,10 @@ 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
 
42
46
  **An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
@@ -1,3 +1,7 @@
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
 
3
7
  **An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
@@ -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
 
@@ -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.
@@ -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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "headgate"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "The async API-client base on httpx2: safe retries, rate budgets, per-origin breakers, credential-safe caching, typed events and declared responses."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -83,7 +83,7 @@ include = [
83
83
  "LICENSE",
84
84
  "pyproject.toml",
85
85
  ]
86
- exclude = [".hypothesis", "**/__pycache__", "**/*.pyc", "mutants", ".coverage*", ".codspeed"]
86
+ exclude = ["docs/assets", ".hypothesis", "**/__pycache__", "**/*.pyc", "mutants", ".coverage*", ".codspeed"]
87
87
 
88
88
  [tool.pytest.ini_options]
89
89
  asyncio_mode = "auto"
@@ -70,6 +70,7 @@ from headgate._options import (
70
70
  HttpOptions,
71
71
  Timeouts,
72
72
  TransportPolicy,
73
+ check_timeout_fits,
73
74
  httpx_timeout,
74
75
  match_route,
75
76
  reject_upstream_httpx,
@@ -82,7 +83,7 @@ from headgate._view import KEEP, ROOT_DEFAULTS, Keep, overlay_timeout
82
83
  from headgate.breaker import BreakerOpenError, CircuitBreaker
83
84
  from headgate.breaker._registry import NO_BREAKER
84
85
  from headgate.breaker._scope import scoped_registry
85
- from headgate.cache._transport import host_untested
86
+ from headgate.cache._transport import host_untested, store_accepted
86
87
  from headgate.errors import (
87
88
  CircuitOpenError,
88
89
  ClientError,
@@ -92,7 +93,7 @@ from headgate.errors import (
92
93
  RateLimitError,
93
94
  )
94
95
  from headgate.pagination import PageRequest, Paginated
95
- from headgate.ratelimit import BudgetPausedError
96
+ from headgate.ratelimit import BudgetBacklogError, BudgetPausedError
96
97
  from headgate.retry import NO_RETRY, REPLAYABLE_BODY, RetryPolicy
97
98
  from headgate.retry._policy import NETWORK_ERRORS, RETRY_OUTCOME, RETRY_POLICY
98
99
  from headgate.streaming import Stream
@@ -263,6 +264,7 @@ class AsyncBaseClient:
263
264
  self._refusal_check = http.refusal_check
264
265
  self._max_response_bytes = http.max_response_bytes
265
266
  self._request_id_headers = http.request_id_headers
267
+ check_timeout_fits(http.timeout, policy.deadline)
266
268
  self._timeout = http.timeout
267
269
  self._emitter = Emitter(http.observers) if http.observers else SILENT
268
270
  self._view = ROOT_DEFAULTS
@@ -316,6 +318,8 @@ class AsyncBaseClient:
316
318
  :raises ValueError: for a timeout or deadline that is not positive.
317
319
  """
318
320
  layered = self._view.layered(headers=headers, deadline=deadline, retry=retry)
321
+ if timeout is not None:
322
+ check_timeout_fits(timeout, self._policy.deadline if layered.deadline is KEEP else layered.deadline)
319
323
  view = copy.copy(self)
320
324
  # A shallow copy shares the pool, the stack and any subclass state; only the view's own settings differ.
321
325
  vars(view).update(
@@ -506,7 +510,10 @@ class AsyncBaseClient:
506
510
  async with self._deadline(prepared), self._exchange(prepared) as (streamed, _):
507
511
  announce_deprecation(prepared.context, streamed)
508
512
  resp = await read_capped(streamed, self._max_response_bytes, self._site(prepared))
509
- return self._dispatch(resp, spec, self._site(prepared))
513
+ answer = self._dispatch(resp, spec, self._site(prepared))
514
+ # Only now that the reader accepted it may a cached miss be stored.
515
+ await store_accepted(resp)
516
+ return answer
510
517
 
511
518
  @contextlib.asynccontextmanager
512
519
  async def _streamed(self, call: _Call) -> AsyncGenerator[Stream]:
@@ -526,6 +533,8 @@ class AsyncBaseClient:
526
533
  if not streamed.is_success:
527
534
  head = await read_head(streamed, _STREAM_ERROR_HEAD_BYTES)
528
535
  raise status_error(head, self._site(prepared))
536
+ # A stream has no reader: passing the head checks is what accepts it.
537
+ await store_accepted(streamed)
529
538
  yield Stream(streamed, self._site(prepared), functools.partial(self._network_failure, prepared, verdict))
530
539
 
531
540
  # Preparing the request.
@@ -570,9 +579,10 @@ class AsyncBaseClient:
570
579
  full_url = sanitize_url(str(request.url)) if self._emitter.active else None
571
580
  info = CallInfo(method=call.method, url=log_url, route=route, full_url=full_url)
572
581
  route_policy = match_route(self._policy.routes, request.url.path)
573
- context = CallContext(
574
- info, clock=self._timing.clock, deadline_after=self._deadline_of(call, route_policy), emitter=self._emitter
575
- )
582
+ deadline = self._deadline_of(call, route_policy)
583
+ if (timeout := options.get("timeout")) is not None:
584
+ check_timeout_fits(timeout, deadline)
585
+ context = CallContext(info, clock=self._timing.clock, deadline_after=deadline, emitter=self._emitter)
576
586
  context.follow_redirects = follow
577
587
  if (retry := self._retry_of(call, route_policy)) is not None:
578
588
  context.put(RETRY_POLICY, retry)
@@ -669,8 +679,12 @@ class AsyncBaseClient:
669
679
  async with asyncio.timeout(remaining):
670
680
  yield
671
681
  except TimeoutError:
672
- site = self._site(prepared)
673
- msg = f"{site.method} {site.url}: deadline exceeded after {prepared.context.elapsed():.3g}s"
682
+ context = prepared.context
683
+ if context.progress.dispatched or context.attempt == 0:
684
+ site, unsent = self._site(prepared), ""
685
+ else:
686
+ site, unsent = self._unsent_site(prepared), f", before attempt {context.attempt} was sent"
687
+ msg = f"{site.method} {site.url}: deadline exceeded after {context.elapsed():.3g}s{unsent}"
674
688
  raise ClientTimeoutError(msg, **site.details()) from None
675
689
 
676
690
  # The exchange: one send under the origin's breaker.
@@ -704,6 +718,14 @@ class AsyncBaseClient:
704
718
  except BreakerOpenError as exc:
705
719
  details = self._site(prepared).details(retry_after=exc.retry_after)
706
720
  raise CircuitOpenError(str(exc), **details) from None
721
+ except BudgetBacklogError as exc:
722
+ # Our own pacing, not the provider's: the call was never sent (the retry layer withdrew the attempt), and
723
+ # sending it later will be admitted.
724
+ site = self._site(prepared)
725
+ details = site.details(retry_after=exc.retry_after)
726
+ details["retryable"] = True
727
+ msg = f"{site.method} {site.url}: never sent: {exc}"
728
+ raise ClientTimeoutError(msg, **details) from None
707
729
  except BudgetPausedError as exc:
708
730
  # Not the retry policy's call: the budget says when sending again will be admitted.
709
731
  details = self._site(prepared).details(retry_after=exc.retry_after)
@@ -831,6 +853,10 @@ class AsyncBaseClient:
831
853
  self._source, info.method, info.url, context.attempt, self._request_id_headers, context.get(RETRY_OUTCOME)
832
854
  )
833
855
 
856
+ def _unsent_site(self, prepared: _Prepared) -> CallSite:
857
+ """Return :meth:`_site` for a call whose current attempt never reached the network: it does not count."""
858
+ return replace(self._site(prepared), attempts=prepared.context.attempt - 1)
859
+
834
860
 
835
861
  def _effective_policy(policy: TransportPolicy, *, off: bool) -> TransportPolicy:
836
862
  """Return the policy a client built now runs: *policy*, less retries and the breaker when transport policy is *off*.
@@ -30,7 +30,7 @@ from headgate._core._events import (
30
30
  )
31
31
  from headgate._core._origin import origin_of, origin_text, same_origin
32
32
  from headgate._core._retry_after import RETRY_AFTER_STATUSES, parse_retry_after, retry_after_of
33
- from headgate._core._seams import BREAKER_GATE, CredentialCarrier, CredentialStamper
33
+ from headgate._core._seams import BREAKER_GATE, BudgetBacklogError, CredentialCarrier, CredentialStamper
34
34
  from headgate._core._switch import policy_disabled, policy_is_disabled
35
35
  from headgate._core._time import (
36
36
  SYSTEM_TIMING,
@@ -53,6 +53,7 @@ __all__ = [
53
53
  "AttemptFinished",
54
54
  "BreakerChanged",
55
55
  "BreakerState",
56
+ "BudgetBacklogError",
56
57
  "CacheOutcome",
57
58
  "CacheResult",
58
59
  "CallContext",
@@ -38,19 +38,39 @@ __all__ = ["CallContext", "SendProgress", "Slot"]
38
38
 
39
39
  #: The one request extension headgate keeps its per-call state in.
40
40
  _EXTENSION: Final = "headgate.call"
41
+ #: The extension holding a request's timeouts as they were before :meth:`CallContext.fit_timeouts` first cut them.
42
+ _REQUESTED_TIMEOUT: Final = "headgate.requested_timeout"
43
+
44
+
45
+ #: A request's httpx2 timeouts as ``(phase, seconds)`` pairs sorted by phase; ``None`` is a phase without a limit.
46
+ type PhaseTimeouts = tuple[tuple[str, float | None], ...]
47
+
48
+
49
+ def _named(timeout: object) -> PhaseTimeouts:
50
+ """Return *timeout*, a request's ``timeout`` extension, as :data:`PhaseTimeouts`; empty when it is not one."""
51
+ if not isinstance(timeout, dict):
52
+ return ()
53
+ # Sorted by phase, which is unique, so the values (a float or None) are never compared.
54
+ pairs: list[tuple[str, float | None]] = sorted((str(phase), limit) for phase, limit in timeout.items())
55
+ return tuple(pairs)
41
56
 
42
57
 
43
58
  @dataclass(slots=True)
44
59
  class SendProgress:
45
- """What one attempt established about sending: whether a connection was obtained, and request bytes written.
60
+ """What one attempt established about sending: whether it was handed to the network, connected, and bytes written.
46
61
 
47
62
  These are facts, recorded by whichever layer can observe them, so the retry layer can tell a request that never
48
63
  left from one that may have been processed.
49
64
  """
50
65
 
66
+ dispatched: bool = False
51
67
  connected: bool = False
52
68
  bytes_sent: int = 0
53
69
 
70
+ def mark_dispatched(self) -> None:
71
+ """Record that the attempt passed the rate budget and was handed to the network."""
72
+ self.dispatched = True
73
+
54
74
  def mark_connected(self) -> None:
55
75
  """Record that the attempt obtained a connection."""
56
76
  self.connected = True
@@ -173,11 +193,26 @@ class CallContext:
173
193
  timeout = request.extensions.get("timeout")
174
194
  if remaining is None or not isinstance(timeout, dict):
175
195
  return
196
+ # Recorded on the request itself: a call's hops (a token mint, say) may ask for timeouts of their own.
197
+ request.extensions.setdefault(_REQUESTED_TIMEOUT, timeout)
176
198
  left = max(remaining, floor)
177
199
  request.extensions["timeout"] = {
178
200
  phase: left if limit is None else min(limit, left) for phase, limit in timeout.items()
179
201
  }
180
202
 
203
+ @staticmethod
204
+ def requested_timeouts(request: httpx2.Request) -> PhaseTimeouts:
205
+ """Return the httpx2 timeouts *request* asked for, before :meth:`fit_timeouts` cut any of them to a deadline.
206
+
207
+ Before any fit, they are its own ``timeout`` extension (none without one).
208
+ """
209
+ return _named(request.extensions.get(_REQUESTED_TIMEOUT, request.extensions.get("timeout")))
210
+
211
+ @staticmethod
212
+ def timeouts_cut(request: httpx2.Request) -> bool:
213
+ """Return whether :meth:`fit_timeouts` cut *request*'s timeouts below the ones it asked for."""
214
+ return _named(request.extensions.get("timeout")) != CallContext.requested_timeouts(request)
215
+
181
216
  # Attempts.
182
217
 
183
218
  @property
@@ -196,6 +231,10 @@ class CallContext:
196
231
  self._progress = SendProgress()
197
232
  return self._attempt
198
233
 
234
+ def withdraw_attempt(self) -> None:
235
+ """Record that the attempt just begun was never sent (its rate-budget turn came too late): it does not count."""
236
+ self._attempt -= 1
237
+
199
238
  # Notes and events.
200
239
 
201
240
  @property
@@ -9,6 +9,9 @@
9
9
  declares, for the guard alone.
10
10
  * :data:`BREAKER_GATE`: set by the client on every call, read by the retry layer, so a retry sequence stops as soon as
11
11
  the origin's breaker opens without the retry layer knowing about breakers.
12
+ * :class:`BudgetBacklogError`: raised by the rate budget for an attempt whose turn comes after the call's deadline. The
13
+ retry layer withdraws the attempt, which was never sent, and keeps the call's last failure when it has one; the
14
+ client raises it as ``ClientTimeoutError``.
12
15
  """
13
16
 
14
17
  from __future__ import annotations
@@ -18,7 +21,7 @@ from typing import Protocol, runtime_checkable
18
21
 
19
22
  from headgate._core._context import Slot
20
23
 
21
- __all__ = ["BREAKER_GATE", "CredentialCarrier", "CredentialStamper"]
24
+ __all__ = ["BREAKER_GATE", "BudgetBacklogError", "CredentialCarrier", "CredentialStamper"]
22
25
 
23
26
 
24
27
  @runtime_checkable
@@ -48,3 +51,16 @@ class CredentialStamper(CredentialCarrier, Protocol):
48
51
  #: Answers whether the breaker for the call's origin is refusing right now. The client puts it on every call's
49
52
  #: context; the retry layer checks it before scheduling another attempt.
50
53
  BREAKER_GATE = Slot[Callable[[], bool]]("breaker_gate")
54
+
55
+
56
+ class BudgetBacklogError(Exception):
57
+ """Raised by ``RateBudget.wait`` when an attempt's paced turn comes after its call's deadline, so it is never sent.
58
+
59
+ ``retry_after`` is the seconds until the turn the attempt would have had. It is public as
60
+ ``headgate.ratelimit.BudgetBacklogError``.
61
+ """
62
+
63
+ def __init__(self, message: str, *, retry_after: float) -> None:
64
+ """Record *message* and *retry_after*."""
65
+ super().__init__(message)
66
+ self.retry_after = retry_after
@@ -48,6 +48,7 @@ __all__ = [
48
48
  "Route",
49
49
  "Timeouts",
50
50
  "TransportPolicy",
51
+ "check_timeout_fits",
51
52
  "httpx_timeout",
52
53
  "match_route",
53
54
  "reject_upstream_httpx",
@@ -116,6 +117,31 @@ def httpx_timeout(client: Timeouts | float, call: Timeouts | float | None = None
116
117
  return httpx2.Timeout(connect=base.connect, read=base.read, write=base.write, pool=base.pool)
117
118
 
118
119
 
120
+ def check_timeout_fits(timeout: Timeouts | float, deadline: float | None) -> None:
121
+ """Refuse a *timeout* someone set that is longer than the *deadline* it runs under, since it could never apply.
122
+
123
+ A bare number is the read timeout. Of a ``Timeouts``, only the phases set away from their defaults are checked, so
124
+ a short deadline over the default timeouts stays valid: there the deadline cuts each attempt's timeouts, as meant.
125
+
126
+ :raises ValueError: naming the phase, its timeout and the deadline.
127
+ """
128
+ if deadline is None:
129
+ return
130
+ defaults = Timeouts()
131
+ if isinstance(timeout, Timeouts):
132
+ phases = [(name, getattr(timeout, name)) for name in ("connect", "read", "write", "pool")]
133
+ chosen = [(name, limit) for name, limit in phases if limit != getattr(defaults, name)]
134
+ else:
135
+ chosen = [("read", timeout)]
136
+ for name, limit in chosen:
137
+ if limit > deadline:
138
+ msg = (
139
+ f"{name} timeout {limit:g}s is longer than the {deadline:g}s deadline it runs under, so it can never "
140
+ f"take effect: raise the deadline (TransportPolicy(deadline=...) or deadline=), or lower the timeout"
141
+ )
142
+ raise ValueError(msg)
143
+
144
+
119
145
  def _uuid4_text() -> str:
120
146
  """Return a random UUID as text."""
121
147
  return str(uuid.uuid4())