headgate 0.3.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.3.0 → headgate-0.4.0}/.gitignore +3 -0
  2. {headgate-0.3.0 → headgate-0.4.0}/CHANGELOG.md +23 -1
  3. {headgate-0.3.0 → headgate-0.4.0}/PKG-INFO +21 -4
  4. {headgate-0.3.0 → headgate-0.4.0}/README.md +19 -2
  5. {headgate-0.3.0 → headgate-0.4.0}/docs/auth.md +1 -1
  6. headgate-0.4.0/docs/comparison.md +50 -0
  7. headgate-0.4.0/docs/index.md +158 -0
  8. {headgate-0.3.0 → headgate-0.4.0}/docs/pagination.md +32 -3
  9. headgate-0.4.0/docs/stylesheets/extra.css +91 -0
  10. {headgate-0.3.0 → headgate-0.4.0}/docs/testing.md +1 -1
  11. headgate-0.4.0/examples/github_client.py +86 -0
  12. {headgate-0.3.0 → headgate-0.4.0}/pyproject.toml +3 -2
  13. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/__init__.py +4 -1
  14. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_client.py +7 -2
  15. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/errors.py +15 -0
  16. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/__init__.py +2 -1
  17. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/_pager.py +23 -7
  18. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/_paginated.py +52 -7
  19. headgate-0.4.0/tests/examples/test_github_client.py +76 -0
  20. {headgate-0.3.0 → headgate-0.4.0}/tests/package/__snapshots__/test_api_surface.ambr +26 -12
  21. {headgate-0.3.0 → headgate-0.4.0}/tests/pagination/test_paginate.py +144 -1
  22. {headgate-0.3.0 → headgate-0.4.0}/LICENSE +0 -0
  23. {headgate-0.3.0 → headgate-0.4.0}/docs/breaker.md +0 -0
  24. {headgate-0.3.0 → headgate-0.4.0}/docs/caching.md +0 -0
  25. {headgate-0.3.0 → headgate-0.4.0}/docs/configuration.md +0 -0
  26. {headgate-0.3.0 → headgate-0.4.0}/docs/deadlines.md +0 -0
  27. {headgate-0.3.0 → headgate-0.4.0}/docs/design.md +0 -0
  28. {headgate-0.3.0 → headgate-0.4.0}/docs/errors.md +0 -0
  29. {headgate-0.3.0 → headgate-0.4.0}/docs/mutation-testing.md +0 -0
  30. {headgate-0.3.0 → headgate-0.4.0}/docs/observability.md +0 -0
  31. {headgate-0.3.0 → headgate-0.4.0}/docs/rate-budgets.md +0 -0
  32. {headgate-0.3.0 → headgate-0.4.0}/docs/responses.md +0 -0
  33. {headgate-0.3.0 → headgate-0.4.0}/docs/retries.md +0 -0
  34. {headgate-0.3.0 → headgate-0.4.0}/docs/security.md +0 -0
  35. {headgate-0.3.0 → headgate-0.4.0}/docs/streaming.md +0 -0
  36. {headgate-0.3.0 → headgate-0.4.0}/examples/__init__.py +0 -0
  37. {headgate-0.3.0 → headgate-0.4.0}/examples/boundary.py +0 -0
  38. {headgate-0.3.0 → headgate-0.4.0}/examples/orders_client.py +0 -0
  39. {headgate-0.3.0 → headgate-0.4.0}/examples/quickstart.py +0 -0
  40. {headgate-0.3.0 → headgate-0.4.0}/examples/series_client.py +0 -0
  41. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_attempts.py +0 -0
  42. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_auth.py +0 -0
  43. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_body.py +0 -0
  44. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_codec.py +0 -0
  45. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/__init__.py +0 -0
  46. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_context.py +0 -0
  47. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_events.py +0 -0
  48. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_origin.py +0 -0
  49. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_retry_after.py +0 -0
  50. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_seams.py +0 -0
  51. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_switch.py +0 -0
  52. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_time.py +0 -0
  53. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_curl.py +0 -0
  54. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_deprecation.py +0 -0
  55. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_failures.py +0 -0
  56. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_middleware.py +0 -0
  57. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_options.py +0 -0
  58. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_redirects.py +0 -0
  59. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_request_body.py +0 -0
  60. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_responses.py +0 -0
  61. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_stack.py +0 -0
  62. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_url.py +0 -0
  63. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_user_agent.py +0 -0
  64. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_view.py +0 -0
  65. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/__init__.py +0 -0
  66. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_breaker.py +0 -0
  67. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_policy.py +0 -0
  68. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_registry.py +0 -0
  69. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_scope.py +0 -0
  70. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/__init__.py +0 -0
  71. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_conditional.py +0 -0
  72. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_credentials.py +0 -0
  73. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_policy.py +0 -0
  74. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_transport.py +0 -0
  75. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/curl.py +0 -0
  76. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/events.py +0 -0
  77. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/health.py +0 -0
  78. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/msgspec.py +0 -0
  79. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/otel.py +0 -0
  80. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/_link.py +0 -0
  81. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/policy.py +0 -0
  82. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/py.typed +0 -0
  83. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pydantic.py +0 -0
  84. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/__init__.py +0 -0
  85. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_budget.py +0 -0
  86. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_budgets.py +0 -0
  87. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_feedback.py +0 -0
  88. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_fields.py +0 -0
  89. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_inflight.py +0 -0
  90. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_rate.py +0 -0
  91. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_transport.py +0 -0
  92. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/__init__.py +0 -0
  93. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_budget.py +0 -0
  94. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_classify.py +0 -0
  95. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_policy.py +0 -0
  96. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_tracking.py +0 -0
  97. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_transport.py +0 -0
  98. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/__init__.py +0 -0
  99. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_addresses.py +0 -0
  100. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_credentials.py +0 -0
  101. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_downgrade.py +0 -0
  102. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_transport.py +0 -0
  103. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/streaming/__init__.py +0 -0
  104. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/streaming/_stream.py +0 -0
  105. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/__init__.py +0 -0
  106. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_breakers.py +0 -0
  107. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_recording.py +0 -0
  108. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_time.py +0 -0
  109. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_transport.py +0 -0
  110. {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/faults.py +0 -0
  111. {headgate-0.3.0 → headgate-0.4.0}/tests/__init__.py +0 -0
  112. {headgate-0.3.0 → headgate-0.4.0}/tests/auth/__init__.py +0 -0
  113. {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_cached_token_auth.py +0 -0
  114. {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_credential_stampers.py +0 -0
  115. {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_flows_are_uniform.py +0 -0
  116. {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_header_and_query_auth.py +0 -0
  117. {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_mint_failures.py +0 -0
  118. {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/__init__.py +0 -0
  119. {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker.py +0 -0
  120. {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_charging.py +0 -0
  121. {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_integration.py +0 -0
  122. {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_keying.py +0 -0
  123. {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_state_machine.py +0 -0
  124. {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_registry.py +0 -0
  125. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/__init__.py +0 -0
  126. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/_helpers.py +0 -0
  127. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_conditional.py +0 -0
  128. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_credentials.py +0 -0
  129. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_events.py +0 -0
  130. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_extension_passthrough.py +0 -0
  131. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_fallback.py +0 -0
  132. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_filters.py +0 -0
  133. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_held_bodies.py +0 -0
  134. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_integration.py +0 -0
  135. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_shared_downloads.py +0 -0
  136. {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_store_on_accept.py +0 -0
  137. {headgate-0.3.0 → headgate-0.4.0}/tests/client/__init__.py +0 -0
  138. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_call_options.py +0 -0
  139. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_client.py +0 -0
  140. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_deadline.py +0 -0
  141. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_error_details.py +0 -0
  142. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_error_messages.py +0 -0
  143. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_events.py +0 -0
  144. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_failure_policy.py +0 -0
  145. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_invalid_url.py +0 -0
  146. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_middleware.py +0 -0
  147. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_options.py +0 -0
  148. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_policy.py +0 -0
  149. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_redirect_identity.py +0 -0
  150. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_response_size_cap.py +0 -0
  151. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_retry_opt_out.py +0 -0
  152. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_timeouts_fit_deadline.py +0 -0
  153. {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_verbs.py +0 -0
  154. {headgate-0.3.0 → headgate-0.4.0}/tests/conftest.py +0 -0
  155. {headgate-0.3.0 → headgate-0.4.0}/tests/core/__init__.py +0 -0
  156. {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_context.py +0 -0
  157. {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_events.py +0 -0
  158. {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_origin.py +0 -0
  159. {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_retry_after.py +0 -0
  160. {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/__init__.py +0 -0
  161. {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_errors.py +0 -0
  162. {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_event_stream.py +0 -0
  163. {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_msgspec.py +0 -0
  164. {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_pydantic.py +0 -0
  165. {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_responses.py +0 -0
  166. {headgate-0.3.0 → headgate-0.4.0}/tests/errors/__init__.py +0 -0
  167. {headgate-0.3.0 → headgate-0.4.0}/tests/errors/__snapshots__/test_error_messages_snapshot.ambr +0 -0
  168. {headgate-0.3.0 → headgate-0.4.0}/tests/errors/test_error_messages_snapshot.py +0 -0
  169. {headgate-0.3.0 → headgate-0.4.0}/tests/examples/__init__.py +0 -0
  170. {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_docs.py +0 -0
  171. {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_orders_client.py +0 -0
  172. {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_quickstart.py +0 -0
  173. {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_series_client.py +0 -0
  174. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/__init__.py +0 -0
  175. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/_support.py +0 -0
  176. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_breaker_gate.py +0 -0
  177. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_cache_and_auths.py +0 -0
  178. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_deadline.py +0 -0
  179. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_destinations.py +0 -0
  180. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_events.py +0 -0
  181. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_hedging.py +0 -0
  182. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_pagination_credentials.py +0 -0
  183. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_replayable_body.py +0 -0
  184. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_retry_and_rate.py +0 -0
  185. {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_views.py +0 -0
  186. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/__init__.py +0 -0
  187. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/__snapshots__/test_snapshots.ambr +0 -0
  188. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/_support.py +0 -0
  189. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_accept.py +0 -0
  190. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_deprecation.py +0 -0
  191. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_otel.py +0 -0
  192. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_otel_isolation.py +0 -0
  193. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_snapshots.py +0 -0
  194. {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_with_options.py +0 -0
  195. {headgate-0.3.0 → headgate-0.4.0}/tests/package/__init__.py +0 -0
  196. {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_api_surface.py +0 -0
  197. {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_boundaries.py +0 -0
  198. {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_contracts.py +0 -0
  199. {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_import_isolation.py +0 -0
  200. {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_metadata.py +0 -0
  201. {headgate-0.3.0 → headgate-0.4.0}/tests/pagination/__init__.py +0 -0
  202. {headgate-0.3.0 → headgate-0.4.0}/tests/pagination/test_link_header.py +0 -0
  203. {headgate-0.3.0 → headgate-0.4.0}/tests/performance/__init__.py +0 -0
  204. {headgate-0.3.0 → headgate-0.4.0}/tests/performance/test_memory.py +0 -0
  205. {headgate-0.3.0 → headgate-0.4.0}/tests/properties/__init__.py +0 -0
  206. {headgate-0.3.0 → headgate-0.4.0}/tests/properties/test_policy_properties.py +0 -0
  207. {headgate-0.3.0 → headgate-0.4.0}/tests/properties/test_url_properties.py +0 -0
  208. {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/__init__.py +0 -0
  209. {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_budget.py +0 -0
  210. {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_client_feedback.py +0 -0
  211. {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_feedback.py +0 -0
  212. {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_in_flight.py +0 -0
  213. {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_rate.py +0 -0
  214. {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_rate_limit_transport.py +0 -0
  215. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/__init__.py +0 -0
  216. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_budget_and_classify.py +0 -0
  217. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_client_retries.py +0 -0
  218. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_faults.py +0 -0
  219. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_parking_and_hedging.py +0 -0
  220. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_policy.py +0 -0
  221. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_properties.py +0 -0
  222. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_tracking.py +0 -0
  223. {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_transport.py +0 -0
  224. {headgate-0.3.0 → headgate-0.4.0}/tests/security/__init__.py +0 -0
  225. {headgate-0.3.0 → headgate-0.4.0}/tests/security/_servers.py +0 -0
  226. {headgate-0.3.0 → headgate-0.4.0}/tests/security/_web.py +0 -0
  227. {headgate-0.3.0 → headgate-0.4.0}/tests/security/certs/ca.pem +0 -0
  228. {headgate-0.3.0 → headgate-0.4.0}/tests/security/certs/server.pem +0 -0
  229. {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_addresses.py +0 -0
  230. {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_credential_origins.py +0 -0
  231. {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_origin_properties.py +0 -0
  232. {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_pinned_transport.py +0 -0
  233. {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_redirect_hardening.py +0 -0
  234. {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_ssrf_guard.py +0 -0
  235. {headgate-0.3.0 → headgate-0.4.0}/tests/streaming/__init__.py +0 -0
  236. {headgate-0.3.0 → headgate-0.4.0}/tests/streaming/test_request_bodies.py +0 -0
  237. {headgate-0.3.0 → headgate-0.4.0}/tests/streaming/test_stream.py +0 -0
  238. {headgate-0.3.0 → headgate-0.4.0}/tests/support/__init__.py +0 -0
  239. {headgate-0.3.0 → headgate-0.4.0}/tests/support/breakers.py +0 -0
  240. {headgate-0.3.0 → headgate-0.4.0}/tests/support/budgets.py +0 -0
  241. {headgate-0.3.0 → headgate-0.4.0}/tests/support/clients.py +0 -0
  242. {headgate-0.3.0 → headgate-0.4.0}/tests/support/events.py +0 -0
  243. {headgate-0.3.0 → headgate-0.4.0}/tests/support/responses.py +0 -0
  244. {headgate-0.3.0 → headgate-0.4.0}/tests/support/tokens.py +0 -0
  245. {headgate-0.3.0 → headgate-0.4.0}/tests/support/wire.py +0 -0
  246. {headgate-0.3.0 → headgate-0.4.0}/tests/testing/__init__.py +0 -0
  247. {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_breakers.py +0 -0
  248. {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_cassettes.py +0 -0
  249. {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_fake_transport.py +0 -0
  250. {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_recording.py +0 -0
  251. {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_time.py +0 -0
  252. {headgate-0.3.0 → headgate-0.4.0}/tests/transport/__init__.py +0 -0
  253. {headgate-0.3.0 → headgate-0.4.0}/tests/transport/test_curl.py +0 -0
  254. {headgate-0.3.0 → headgate-0.4.0}/tests/transport/test_health.py +0 -0
  255. {headgate-0.3.0 → headgate-0.4.0}/tests/urls/__init__.py +0 -0
  256. {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_empty_segment.py +0 -0
  257. {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_path_escaping.py +0 -0
  258. {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_route_template.py +0 -0
  259. {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_sanitize_url.py +0 -0
  260. {headgate-0.3.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,27 @@ 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
+
9
30
  ## [0.3.0] - 2026-10-26
10
31
 
11
32
  Fixes four bugs where a call's deadline, its timeouts, the rate budget and the cache disagreed. One of them makes a
@@ -215,7 +236,8 @@ Python 3.14.
215
236
  - The `headgate.msgspec` and `headgate.pydantic` codec flavors, the optional `AsyncCurlTransport` (`headgate[curl]`),
216
237
  `headgate.health` snapshots and `headgate.policy.policy_disabled()`.
217
238
 
218
- [Unreleased]: https://github.com/kelsayed17/headgate/compare/v0.3.0...HEAD
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
219
241
  [0.3.0]: https://github.com/kelsayed17/headgate/compare/159a3c2...v0.3.0
220
242
  [0.2.0]: https://github.com/kelsayed17/headgate/compare/f319ad8...159a3c2
221
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.3.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
@@ -43,6 +43,13 @@ Description-Content-Type: text/markdown
43
43
 
44
44
  # headgate
45
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
+
46
53
  **An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
47
54
  `AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour:
48
55
  retries that re-send only when it is safe, a shared rate budget that listens to the server, a per-origin circuit
@@ -52,10 +59,19 @@ events and OpenTelemetry, and errors that say exactly what happened. Each endpoi
52
59
  A headgate controls how much water gets through a channel. This library does the same for requests, in one place,
53
60
  so that no client has to get it right on its own.
54
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
+
55
65
  ## Requirements and install
56
66
 
57
- Python 3.14 or newer (endpoint paths are t-strings) and `httpx2`, pydantic's fork of httpx. Upstream `httpx` is not
58
- 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).
59
75
 
60
76
  ```bash
61
77
  pip install headgate # or: uv add headgate. Brings httpx2[http2], hishel and msgspec
@@ -364,6 +380,7 @@ uv run ty check
364
380
  uv run python -m pytest --cov # 100% line and branch coverage is required
365
381
  uv run python -m pytest benchmarks # the benchmarks, as CI runs them
366
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
367
384
  ```
368
385
 
369
386
  Lint selects every ruff rule, ty treats every rule as an error, and suppression comments are not accepted. See
@@ -4,6 +4,13 @@
4
4
 
5
5
  # headgate
6
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
+
7
14
  **An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
8
15
  `AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour:
9
16
  retries that re-send only when it is safe, a shared rate budget that listens to the server, a per-origin circuit
@@ -13,10 +20,19 @@ events and OpenTelemetry, and errors that say exactly what happened. Each endpoi
13
20
  A headgate controls how much water gets through a channel. This library does the same for requests, in one place,
14
21
  so that no client has to get it right on its own.
15
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
+
16
26
  ## Requirements and install
17
27
 
18
- Python 3.14 or newer (endpoint paths are t-strings) and `httpx2`, pydantic's fork of httpx. Upstream `httpx` is not
19
- 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).
20
36
 
21
37
  ```bash
22
38
  pip install headgate # or: uv add headgate. Brings httpx2[http2], hishel and msgspec
@@ -325,6 +341,7 @@ uv run ty check
325
341
  uv run python -m pytest --cov # 100% line and branch coverage is required
326
342
  uv run python -m pytest benchmarks # the benchmarks, as CI runs them
327
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
328
345
  ```
329
346
 
330
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`.
@@ -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.
@@ -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
@@ -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
 
@@ -0,0 +1,86 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """A client for a few GitHub REST endpoints: the API everyone knows, written as a headgate client.
5
+
6
+ GitHub needs a bearer token, two fixed headers, pagination by ``Link`` header, a 404 for a repository that does not
7
+ exist (or that the token cannot see), and pacing under its limits. headgate covers each with a declaration, so every
8
+ endpoint method stays one line:
9
+
10
+ * the rate budget reads GitHub's ``X-RateLimit-Remaining`` / ``X-RateLimit-Reset`` headers by default, and pauses every
11
+ call until the reset when the hourly limit is spent, instead of spending requests on certain refusals;
12
+ * ``max_in_flight`` keeps under GitHub's limit on concurrent requests;
13
+ * the bearer token never leaves ``api.github.com``, even when a ``Link`` header points elsewhere.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from http import HTTPStatus
19
+ from typing import TYPE_CHECKING
20
+
21
+ import msgspec
22
+
23
+ from headgate import AsyncBaseClient, Expect, HttpOptions, LinkHeader, Rate, RateBudget, StaticHeaderAuth, nothing
24
+ from headgate.msgspec import decoder
25
+
26
+ if TYPE_CHECKING:
27
+ import httpx2
28
+
29
+ from headgate import Paginated
30
+
31
+ API = "https://api.github.com"
32
+
33
+
34
+ class Repository(msgspec.Struct, frozen=True):
35
+ """The fields of a repository this client reads; GitHub sends many more, which are ignored."""
36
+
37
+ full_name: str
38
+ description: str | None
39
+ stargazers_count: int
40
+ default_branch: str
41
+
42
+
43
+ class Issue(msgspec.Struct, frozen=True):
44
+ """An issue. GitHub lists pull requests as issues too; ``pull_request`` is set on those."""
45
+
46
+ number: int
47
+ title: str
48
+ pull_request: dict[str, object] | None = None
49
+
50
+
51
+ def _issues(page: list[Issue]) -> list[Issue]:
52
+ """Return the issues on a page, which is a JSON list."""
53
+ return page
54
+
55
+
56
+ # Readers and pagers are values: build them once, at import, where a bad declaration fails.
57
+ REPOSITORY = decoder(Repository)
58
+ REPOSITORY_OR_NONE = Expect(REPOSITORY).on(HTTPStatus.NOT_FOUND, nothing) # a missing or hidden repository
59
+ ISSUES = decoder(list[Issue])
60
+ ISSUE_PAGES = LinkHeader(items=_issues)
61
+
62
+
63
+ class GitHub(AsyncBaseClient):
64
+ """Read repositories and their open issues from the GitHub REST API."""
65
+
66
+ def __init__(self, token: str, *, transport: httpx2.AsyncBaseTransport | None = None) -> None:
67
+ """Authenticate with *token*; *transport* replaces the network, as a test does."""
68
+ super().__init__(
69
+ base_url=API,
70
+ # Well under the 5,000 requests an hour a token gets, and never more than 50 at once.
71
+ rate=RateBudget(Rate.per_second(10), max_in_flight=50),
72
+ auth=StaticHeaderAuth({"Authorization": f"Bearer {token}"}),
73
+ http=HttpOptions(
74
+ transport=transport,
75
+ headers={"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"},
76
+ ),
77
+ )
78
+
79
+ async def repository(self, owner: str, name: str) -> Repository | None:
80
+ """Return the repository *owner*/*name*, or ``None`` when it does not exist or the token cannot see it."""
81
+ return await self.get(t"repos/{owner}/{name}", expect=REPOSITORY_OR_NONE)
82
+
83
+ def open_issues(self, owner: str, name: str) -> Paginated[list[Issue], Issue]:
84
+ """Return the open issues (and pull requests) of *owner*/*name*, 100 a page, fetched as they are iterated."""
85
+ params = {"state": "open", "per_page": 100}
86
+ return self.paginate(t"repos/{owner}/{name}/issues", expect=ISSUES, pages=ISSUE_PAGES, params=params)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "headgate"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -44,7 +44,7 @@ cache = ["hishel[async]>=1.4.0"]
44
44
 
45
45
  [project.urls]
46
46
  Homepage = "https://github.com/kelsayed17/headgate"
47
- Documentation = "https://github.com/kelsayed17/headgate/tree/main/docs"
47
+ Documentation = "https://kelsayed17.github.io/headgate/"
48
48
  Repository = "https://github.com/kelsayed17/headgate"
49
49
  Issues = "https://github.com/kelsayed17/headgate/issues"
50
50
  Changelog = "https://github.com/kelsayed17/headgate/blob/main/CHANGELOG.md"
@@ -67,6 +67,7 @@ dev = [
67
67
  "mutmut>=3.8",
68
68
  "vcrpy>=8.3",
69
69
  ]
70
+ docs = ["zensical>=0.0.72"]
70
71
 
71
72
  [tool.hatch.build.targets.wheel]
72
73
  packages = ["src/headgate"]
@@ -51,6 +51,7 @@ from headgate.errors import (
51
51
  ClientTimeoutError,
52
52
  DecodeError,
53
53
  ForbiddenDestinationError,
54
+ PageLimitError,
54
55
  Problem,
55
56
  RateLimitError,
56
57
  ResponseParseError,
@@ -58,7 +59,7 @@ from headgate.errors import (
58
59
  TransportError,
59
60
  UpstreamStatusError,
60
61
  )
61
- from headgate.pagination import Cursor, LinkHeader, NextUrl, Offset, Pager, PageRequest, Paginated
62
+ from headgate.pagination import Cursor, LinkHeader, NextUrl, Offset, PageLimit, Pager, PageRequest, Paginated
62
63
  from headgate.ratelimit import Adaptive, Rate, RateBudget, RateRule
63
64
  from headgate.retry import NO_RETRY, Backoff, Hedge, RetryBudget, RetryPolicy
64
65
  from headgate.streaming import Stream
@@ -95,6 +96,8 @@ __all__ = [
95
96
  "NotModified",
96
97
  "Observer",
97
98
  "Offset",
99
+ "PageLimit",
100
+ "PageLimitError",
98
101
  "PageRequest",
99
102
  "Pager",
100
103
  "Paginated",