headgate 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {headgate-0.2.0 → headgate-0.4.0}/.gitignore +3 -0
- {headgate-0.2.0 → headgate-0.4.0}/CHANGELOG.md +55 -2
- {headgate-0.2.0 → headgate-0.4.0}/PKG-INFO +25 -4
- {headgate-0.2.0 → headgate-0.4.0}/README.md +23 -2
- {headgate-0.2.0 → headgate-0.4.0}/docs/auth.md +1 -1
- {headgate-0.2.0 → headgate-0.4.0}/docs/caching.md +14 -7
- headgate-0.4.0/docs/comparison.md +50 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/deadlines.md +18 -1
- headgate-0.4.0/docs/index.md +158 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/pagination.md +32 -3
- {headgate-0.2.0 → headgate-0.4.0}/docs/rate-budgets.md +2 -1
- headgate-0.4.0/docs/stylesheets/extra.css +91 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/testing.md +1 -1
- headgate-0.4.0/examples/github_client.py +86 -0
- {headgate-0.2.0 → headgate-0.4.0}/pyproject.toml +4 -3
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/__init__.py +4 -1
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_client.py +41 -10
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/__init__.py +2 -1
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_context.py +40 -1
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_seams.py +17 -1
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_options.py +26 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_transport.py +109 -32
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/errors.py +15 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/__init__.py +2 -1
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/_pager.py +23 -7
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/_paginated.py +52 -7
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/__init__.py +2 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_budget.py +7 -1
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_transport.py +1 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_transport.py +37 -9
- {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_mint_failures.py +3 -2
- {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_charging.py +13 -3
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/_helpers.py +8 -1
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_credentials.py +3 -3
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_fallback.py +80 -56
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_held_bodies.py +2 -2
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_shared_downloads.py +62 -0
- headgate-0.4.0/tests/cache/test_store_on_accept.py +146 -0
- headgate-0.4.0/tests/client/test_timeouts_fit_deadline.py +100 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_context.py +27 -0
- headgate-0.4.0/tests/examples/test_github_client.py +76 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_deadline.py +50 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/package/__snapshots__/test_api_surface.ambr +28 -13
- {headgate-0.2.0 → headgate-0.4.0}/tests/pagination/test_paginate.py +144 -1
- {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_budget.py +25 -1
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_parking_and_hedging.py +81 -1
- {headgate-0.2.0 → headgate-0.4.0}/LICENSE +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/breaker.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/configuration.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/design.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/errors.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/mutation-testing.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/observability.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/responses.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/retries.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/security.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/docs/streaming.md +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/examples/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/examples/boundary.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/examples/orders_client.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/examples/quickstart.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/examples/series_client.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_attempts.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_auth.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_body.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_codec.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_events.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_origin.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_retry_after.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_switch.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_core/_time.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_curl.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_deprecation.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_failures.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_middleware.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_redirects.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_request_body.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_responses.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_stack.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_url.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_user_agent.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/_view.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_breaker.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_policy.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_registry.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/breaker/_scope.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_conditional.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_credentials.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/cache/_policy.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/curl.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/events.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/health.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/msgspec.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/otel.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pagination/_link.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/policy.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/py.typed +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/pydantic.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_budgets.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_feedback.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_fields.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_inflight.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/ratelimit/_rate.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_budget.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_classify.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_policy.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/retry/_tracking.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_addresses.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_credentials.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_downgrade.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/security/_transport.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/streaming/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/streaming/_stream.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_breakers.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_recording.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_time.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/_transport.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/src/headgate/testing/faults.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/auth/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_cached_token_auth.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_credential_stampers.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_flows_are_uniform.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/auth/test_header_and_query_auth.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_integration.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_keying.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_breaker_state_machine.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/breaker/test_registry.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_conditional.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_events.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_extension_passthrough.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_filters.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/cache/test_integration.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_call_options.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_client.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_deadline.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_error_details.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_error_messages.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_events.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_failure_policy.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_invalid_url.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_middleware.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_options.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_policy.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_redirect_identity.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_response_size_cap.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_retry_opt_out.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/client/test_verbs.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/conftest.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/core/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_events.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_origin.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/core/test_retry_after.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_errors.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_event_stream.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_msgspec.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_pydantic.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/decoding/test_responses.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/errors/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/errors/__snapshots__/test_error_messages_snapshot.ambr +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/errors/test_error_messages_snapshot.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/examples/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_docs.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_orders_client.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_quickstart.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/examples/test_series_client.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/_support.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_breaker_gate.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_cache_and_auths.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_destinations.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_events.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_hedging.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_pagination_credentials.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_replayable_body.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_retry_and_rate.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/integration/test_views.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/__snapshots__/test_snapshots.ambr +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/_support.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_accept.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_deprecation.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_otel.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_otel_isolation.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_snapshots.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/observe/test_with_options.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/package/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_api_surface.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_boundaries.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_contracts.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_import_isolation.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/package/test_metadata.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/pagination/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/pagination/test_link_header.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/performance/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/performance/test_memory.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/properties/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/properties/test_policy_properties.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/properties/test_url_properties.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_client_feedback.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_feedback.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_in_flight.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_rate.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/ratelimit/test_rate_limit_transport.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_budget_and_classify.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_client_retries.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_faults.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_policy.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_properties.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_tracking.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/retry/test_transport.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/_servers.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/_web.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/certs/ca.pem +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/certs/server.pem +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_addresses.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_credential_origins.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_origin_properties.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_pinned_transport.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_redirect_hardening.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/security/test_ssrf_guard.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/streaming/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/streaming/test_request_bodies.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/streaming/test_stream.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/breakers.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/budgets.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/clients.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/events.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/responses.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/tokens.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/support/wire.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/testing/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_breakers.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_cassettes.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_fake_transport.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_recording.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/testing/test_time.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/transport/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/transport/test_curl.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/transport/test_health.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/urls/__init__.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_empty_segment.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_path_escaping.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_route_template.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_sanitize_url.py +0 -0
- {headgate-0.2.0 → headgate-0.4.0}/tests/urls/test_secret_query_params.py +0 -0
|
@@ -6,6 +6,57 @@ change the public API; such changes are listed under **Changed** and **Removed**
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-10-26
|
|
10
|
+
|
|
11
|
+
Bounds `paginate()`: a page budget, and a stop at pages with no items. The stop changes what three pagers do by
|
|
12
|
+
default, so this is a minor release: see **Changed**.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- A page budget for `paginate()`: `limit=PageLimit(max_pages)` raises the new `PageLimitError` once the budget is spent
|
|
17
|
+
with pages still to come, or with `on_limit="stop"` ends the walk there
|
|
18
|
+
([#14](https://github.com/kelsayed17/headgate/issues/14)).
|
|
19
|
+
- A documentation site at <https://kelsayed17.github.io/headgate/>, built from `docs/`, with a page comparing headgate
|
|
20
|
+
with tenacity, stamina and a hand-rolled client.
|
|
21
|
+
- `examples/github_client.py`: a tested client for a few GitHub REST endpoints.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `Cursor`, `NextUrl` and `LinkHeader` stop at a page with no items, as `Offset` already did, instead of following its
|
|
26
|
+
link: a link that advances while returning nothing made the walk spend requests for nothing. Pass
|
|
27
|
+
`stop_on_empty=False` for an endpoint that sends empty pages mid-walk
|
|
28
|
+
([#15](https://github.com/kelsayed17/headgate/issues/15)).
|
|
29
|
+
|
|
30
|
+
## [0.3.0] - 2026-10-26
|
|
31
|
+
|
|
32
|
+
Fixes four bugs where a call's deadline, its timeouts, the rate budget and the cache disagreed. One of them makes a
|
|
33
|
+
configuration that could never work an error, so this is a minor release: see **Changed**.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `headgate.ratelimit.BudgetBacklogError`, raised by `RateBudget.wait` when a call's paced turn comes after its deadline.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- A timeout longer than the deadline it runs under is refused with `ValueError` on the client, a view or a call, since
|
|
42
|
+
it could never take effect ([#6](https://github.com/kelsayed17/headgate/issues/6)). Raise the deadline with it, e.g.
|
|
43
|
+
`with_options(timeout=300.0, deadline=600.0)`.
|
|
44
|
+
- A body past `max_response_bytes` is no longer stored in the cache, since it is not held to be written on accept.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- A cached miss is stored only once the call's reader accepts it, so a refusal inside a 200, an undecodable body or an
|
|
49
|
+
undeclared status is no longer served from the cache for the entry's lifetime
|
|
50
|
+
([#9](https://github.com/kelsayed17/headgate/issues/9)). A `stream()` is accepted when its head checks pass.
|
|
51
|
+
- Identical concurrent GETs share one cached download again when their timeouts are longer than the time their deadline
|
|
52
|
+
leaves; the sharing key now uses the timeouts the call asked for, not the deadline-cut ones
|
|
53
|
+
([#4](https://github.com/kelsayed17/headgate/issues/4)).
|
|
54
|
+
- A call whose rate-budget turn comes after its deadline fails at once with `ClientTimeoutError` saying it was never
|
|
55
|
+
sent, with `retryable=True` and `retry_after`, instead of waiting and failing like a slow upstream; a retry that
|
|
56
|
+
cannot be sent in time ends the call with the failure it already has, and such a hedge is not sent. A deadline that
|
|
57
|
+
passes before an attempt is sent says so, and `attempts` counts only the sends made
|
|
58
|
+
([#5](https://github.com/kelsayed17/headgate/issues/5)).
|
|
59
|
+
|
|
9
60
|
## [0.2.0] - 2026-10-25
|
|
10
61
|
|
|
11
62
|
The first release published to PyPI. It reshapes the client around option objects and rebuilds every policy
|
|
@@ -185,6 +236,8 @@ Python 3.14.
|
|
|
185
236
|
- The `headgate.msgspec` and `headgate.pydantic` codec flavors, the optional `AsyncCurlTransport` (`headgate[curl]`),
|
|
186
237
|
`headgate.health` snapshots and `headgate.policy.policy_disabled()`.
|
|
187
238
|
|
|
188
|
-
[Unreleased]: https://github.com/kelsayed17/headgate/compare/v0.
|
|
189
|
-
[0.
|
|
239
|
+
[Unreleased]: https://github.com/kelsayed17/headgate/compare/v0.4.0...HEAD
|
|
240
|
+
[0.4.0]: https://github.com/kelsayed17/headgate/compare/v0.3.0...v0.4.0
|
|
241
|
+
[0.3.0]: https://github.com/kelsayed17/headgate/compare/159a3c2...v0.3.0
|
|
242
|
+
[0.2.0]: https://github.com/kelsayed17/headgate/compare/f319ad8...159a3c2
|
|
190
243
|
[0.1.0]: https://github.com/kelsayed17/headgate/commit/f319ad8
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: headgate
|
|
3
|
-
Version: 0.
|
|
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.
|
|
6
|
+
Project-URL: Documentation, https://kelsayed17.github.io/headgate/
|
|
7
7
|
Project-URL: Repository, https://github.com/kelsayed17/headgate
|
|
8
8
|
Project-URL: Issues, https://github.com/kelsayed17/headgate/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/kelsayed17/headgate/blob/main/CHANGELOG.md
|
|
@@ -37,8 +37,19 @@ Provides-Extra: pydantic
|
|
|
37
37
|
Requires-Dist: pydantic>=2.14.1; extra == 'pydantic'
|
|
38
38
|
Description-Content-Type: text/markdown
|
|
39
39
|
|
|
40
|
+
<p align="center">
|
|
41
|
+
<img src="https://raw.githubusercontent.com/kelsayed17/headgate/main/docs/assets/logo.png" alt="headgate logo: a gate metering a stream of requests" width="160">
|
|
42
|
+
</p>
|
|
43
|
+
|
|
40
44
|
# headgate
|
|
41
45
|
|
|
46
|
+
[](https://pypi.org/project/headgate/)
|
|
47
|
+
[](https://pypi.org/project/headgate/)
|
|
48
|
+
[](https://github.com/kelsayed17/headgate/actions/workflows/ci.yml)
|
|
49
|
+
[](CONTRIBUTING.md)
|
|
50
|
+
[](https://kelsayed17.github.io/headgate/)
|
|
51
|
+
[](LICENSE)
|
|
52
|
+
|
|
42
53
|
**An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
|
|
43
54
|
`AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour:
|
|
44
55
|
retries that re-send only when it is safe, a shared rate budget that listens to the server, a per-origin circuit
|
|
@@ -48,10 +59,19 @@ events and OpenTelemetry, and errors that say exactly what happened. Each endpoi
|
|
|
48
59
|
A headgate controls how much water gets through a channel. This library does the same for requests, in one place,
|
|
49
60
|
so that no client has to get it right on its own.
|
|
50
61
|
|
|
62
|
+
**Docs:** <https://kelsayed17.github.io/headgate/>. New here? See [how headgate compares](docs/comparison.md) with
|
|
63
|
+
tenacity, stamina and a hand-rolled client, and a [GitHub API client](examples/github_client.py) written with it.
|
|
64
|
+
|
|
51
65
|
## Requirements and install
|
|
52
66
|
|
|
53
|
-
Python 3.14 or newer
|
|
54
|
-
|
|
67
|
+
Python 3.14 or newer, and `httpx2`, pydantic's fork of httpx.
|
|
68
|
+
|
|
69
|
+
- **Why 3.14:** endpoint paths are [template strings](https://peps.python.org/pep-0750/) (`t"orders/{order_id}"`), so
|
|
70
|
+
each value is escaped into its own path segment by default: `BTC/USD` becomes `BTC%2FUSD` instead of a new path.
|
|
71
|
+
Template strings exist only from Python 3.14.
|
|
72
|
+
- **Why httpx2:** headgate is built and tested on it alone. Its API is httpx's, so moving a client over is mostly
|
|
73
|
+
`import httpx2` where you had `import httpx`. Upstream `httpx` is not supported: passing one of its objects raises a
|
|
74
|
+
`TypeError` naming the mistake. See [why httpx2](docs/comparison.md#why-httpx2).
|
|
55
75
|
|
|
56
76
|
```bash
|
|
57
77
|
pip install headgate # or: uv add headgate. Brings httpx2[http2], hishel and msgspec
|
|
@@ -360,6 +380,7 @@ uv run ty check
|
|
|
360
380
|
uv run python -m pytest --cov # 100% line and branch coverage is required
|
|
361
381
|
uv run python -m pytest benchmarks # the benchmarks, as CI runs them
|
|
362
382
|
uv run mutmut run # mutation testing; slow, run it before a release
|
|
383
|
+
uv run --only-group docs zensical serve # preview the docs site at http://localhost:8000
|
|
363
384
|
```
|
|
364
385
|
|
|
365
386
|
Lint selects every ruff rule, ty treats every rule as an error, and suppression comments are not accepted. See
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/kelsayed17/headgate/main/docs/assets/logo.png" alt="headgate logo: a gate metering a stream of requests" width="160">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# headgate
|
|
2
6
|
|
|
7
|
+
[](https://pypi.org/project/headgate/)
|
|
8
|
+
[](https://pypi.org/project/headgate/)
|
|
9
|
+
[](https://github.com/kelsayed17/headgate/actions/workflows/ci.yml)
|
|
10
|
+
[](CONTRIBUTING.md)
|
|
11
|
+
[](https://kelsayed17.github.io/headgate/)
|
|
12
|
+
[](LICENSE)
|
|
13
|
+
|
|
3
14
|
**An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
|
|
4
15
|
`AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour:
|
|
5
16
|
retries that re-send only when it is safe, a shared rate budget that listens to the server, a per-origin circuit
|
|
@@ -9,10 +20,19 @@ events and OpenTelemetry, and errors that say exactly what happened. Each endpoi
|
|
|
9
20
|
A headgate controls how much water gets through a channel. This library does the same for requests, in one place,
|
|
10
21
|
so that no client has to get it right on its own.
|
|
11
22
|
|
|
23
|
+
**Docs:** <https://kelsayed17.github.io/headgate/>. New here? See [how headgate compares](docs/comparison.md) with
|
|
24
|
+
tenacity, stamina and a hand-rolled client, and a [GitHub API client](examples/github_client.py) written with it.
|
|
25
|
+
|
|
12
26
|
## Requirements and install
|
|
13
27
|
|
|
14
|
-
Python 3.14 or newer
|
|
15
|
-
|
|
28
|
+
Python 3.14 or newer, and `httpx2`, pydantic's fork of httpx.
|
|
29
|
+
|
|
30
|
+
- **Why 3.14:** endpoint paths are [template strings](https://peps.python.org/pep-0750/) (`t"orders/{order_id}"`), so
|
|
31
|
+
each value is escaped into its own path segment by default: `BTC/USD` becomes `BTC%2FUSD` instead of a new path.
|
|
32
|
+
Template strings exist only from Python 3.14.
|
|
33
|
+
- **Why httpx2:** headgate is built and tested on it alone. Its API is httpx's, so moving a client over is mostly
|
|
34
|
+
`import httpx2` where you had `import httpx`. Upstream `httpx` is not supported: passing one of its objects raises a
|
|
35
|
+
`TypeError` naming the mistake. See [why httpx2](docs/comparison.md#why-httpx2).
|
|
16
36
|
|
|
17
37
|
```bash
|
|
18
38
|
pip install headgate # or: uv add headgate. Brings httpx2[http2], hishel and msgspec
|
|
@@ -321,6 +341,7 @@ uv run ty check
|
|
|
321
341
|
uv run python -m pytest --cov # 100% line and branch coverage is required
|
|
322
342
|
uv run python -m pytest benchmarks # the benchmarks, as CI runs them
|
|
323
343
|
uv run mutmut run # mutation testing; slow, run it before a release
|
|
344
|
+
uv run --only-group docs zensical serve # preview the docs site at http://localhost:8000
|
|
324
345
|
```
|
|
325
346
|
|
|
326
347
|
Lint selects every ruff rule, ty treats every rule as an error, and suppression comments are not accepted. See
|
|
@@ -65,7 +65,7 @@ class PartnerAuth(CachedTokenAuth):
|
|
|
65
65
|
|
|
66
66
|
Or `_mint_flow(request)` yields the requests the exchange needs, so they travel through the owning client's own stack
|
|
67
67
|
(its transport, rate budget, retries and error mapping), and ends by yielding `Minted(token, lifetime)`.
|
|
68
|
-
[`examples/orders_client.py`](
|
|
68
|
+
[`examples/orders_client.py`](https://github.com/kelsayed17/headgate/blob/main/examples/orders_client.py) has a complete one. What the flow can count on:
|
|
69
69
|
|
|
70
70
|
- **Each response arrives read**, so `.content`, `.text` and `.json()` work on it. It is read up to 1 MiB
|
|
71
71
|
(`headgate.MAX_MINT_RESPONSE_BYTES`, decompressed); a larger one fails the mint with `ResponseTooLargeError`.
|
|
@@ -30,13 +30,18 @@ policy = TransportPolicy(cache=cache)
|
|
|
30
30
|
- **`policy`** is a hishel `FilterPolicy`. headgate supplies three filters: `PathFragmentFilter(fragments)` (the URL path
|
|
31
31
|
contains one of them), `MethodFilter(methods)` and `StatusCodeFilter(codes)`. A `FilterPolicy` stores whenever a
|
|
32
32
|
request filter matches and an allowed status comes back, **even past a `no-store`**, which is the point for a
|
|
33
|
-
provider that sends no cache headers, or wrong ones.
|
|
34
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
means the same URL, every header, the same timeouts and the same retry policy; the timeouts compared are the ones the
|
|
75
|
+
call asked for, not the ones its deadline cut, so calls with different time left still share. A timeout that only the
|
|
76
|
+
first request's deadline cut short is not passed on: each waiting request then fetches its own. A network failure fails
|
|
77
|
+
every waiting request from the one attempt, each with its own copy of the error, and each caller's retry layer then
|
|
78
|
+
retries (and shares again). Only the request that fetched counts toward the [breaker](breaker.md). A body past the size
|
|
79
|
+
cap is not held, so each waiting request fetches its own. POSTs are never shared.
|
|
73
80
|
|
|
74
81
|
## Storage faults
|
|
75
82
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
hide:
|
|
3
|
+
- toc
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Compared with others
|
|
7
|
+
|
|
8
|
+
headgate is a base for **HTTP API clients**: it owns the whole call, from building the request to reading the body,
|
|
9
|
+
and handles the concerns every client shares. The libraries people usually reach for each solve one of those concerns,
|
|
10
|
+
for any code. This page says what that difference buys, and when to use them instead.
|
|
11
|
+
|
|
12
|
+
## At a glance
|
|
13
|
+
|
|
14
|
+
<div class="hg-compare" markdown>
|
|
15
|
+
|
|
16
|
+
| | tenacity | stamina | a hand-rolled httpx client | headgate |
|
|
17
|
+
| --- | --- | --- | --- | --- |
|
|
18
|
+
| Scope | retry any callable | retry any callable (an opinionated tenacity wrapper) | what you write | HTTP API clients |
|
|
19
|
+
| What is retried | the whole decorated call | the whole decorated call | what you write | only the send: below auth and the cache, so a cache hit is never retried and each re-send spends its own rate turn |
|
|
20
|
+
| Which requests are safe to re-send | your predicate | your `on=` | what you write | idempotent methods; a POST or PATCH only when it provably never left, or under an idempotency key stamped once per call |
|
|
21
|
+
| `Retry-After` | write it yourself | write it yourself | write it yourself | waited exactly, up to a cap; past it the call fails at once with `retry_after` |
|
|
22
|
+
| Total deadline | `stop_after_delay` | `timeout=` | write it yourself | one deadline over every attempt, backoff, rate-budget wait and the body read |
|
|
23
|
+
| Retry budget across calls | no | no | write it yourself | yes, so retries cannot multiply an outage |
|
|
24
|
+
| Rate limiting | no | no | add aiolimiter or similar | a shared budget that reads `RateLimit` and `X-RateLimit-*` headers and backs off on pushback |
|
|
25
|
+
| Circuit breaker | no | no | add a breaker library | per origin, thread-safe, charged only for the host's own failures |
|
|
26
|
+
| Caching | no | no | add hishel | hishel, partitioned by credential, never storing credentials, storing only what the call accepted |
|
|
27
|
+
| Credential safety | n/a | n/a | write it yourself | credentials pinned to their origin, an https-to-http redirect refused, an optional SSRF guard |
|
|
28
|
+
| Observability | `before_sleep` and other hooks | instrumentation built in | write it yourself | typed events and an OpenTelemetry observer |
|
|
29
|
+
| Test support | none built in | `set_testing()` | patch what you must | `headgate.testing`: a fake network, instant retries, failure schedules, a test clock |
|
|
30
|
+
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
## When to use something else
|
|
34
|
+
|
|
35
|
+
- **Retrying code that is not an HTTP call** (a database query, a file operation): use stamina, or tenacity when you
|
|
36
|
+
need its full flexibility. headgate's retry lives in its transport stack and retries only HTTP sends.
|
|
37
|
+
- **Python before 3.14**: headgate needs 3.14 for its template-string paths. Use httpx with stamina or tenacity, and
|
|
38
|
+
add rate limiting and caching yourself.
|
|
39
|
+
- **Upstream httpx**: headgate runs on httpx2 and refuses httpx objects, to catch the mix-up early. If your code has to
|
|
40
|
+
stay on httpx, headgate is not for you yet.
|
|
41
|
+
- **A synchronous codebase**: headgate is async only.
|
|
42
|
+
- **One or two calls in a script**: a plain httpx2 call is fine. headgate pays off when a client has several endpoints,
|
|
43
|
+
several consumers, or has to survive a provider's bad day.
|
|
44
|
+
|
|
45
|
+
## Why httpx2
|
|
46
|
+
|
|
47
|
+
headgate is built and tested on [httpx2](https://pypi.org/project/httpx2/), pydantic's fork of httpx, and on nothing
|
|
48
|
+
else. Its API is httpx's, so moving a client over is mostly an import change: `import httpx2` where you had
|
|
49
|
+
`import httpx`, and the same for the exceptions you catch. headgate refuses an `httpx` object anywhere it takes an httpx2
|
|
50
|
+
one, with a `TypeError` naming the mistake, rather than failing later in a confusing way.
|
|
@@ -32,8 +32,15 @@ below act on it as well as the outer timer:
|
|
|
32
32
|
- the retry layer starts no attempt or wait with less than `RetryPolicy.min_attempt_time` (0.05 s) left, and raises
|
|
33
33
|
the last failure with the note `deadline exhausted` instead;
|
|
34
34
|
- a rate-budget pause that would outlast the deadline raises `RateLimitError` at once rather than waiting;
|
|
35
|
+
- a turn in the rate budget that comes at or after the deadline is not waited for. On the first attempt the call
|
|
36
|
+
raises `ClientTimeoutError` at once, naming it as never sent, with `attempts=0`, `retryable=True` and `retry_after`
|
|
37
|
+
the wait it was refused. On a retry, the call ends with the failure it already has, noted `deadline exhausted`, and a
|
|
38
|
+
hedge is simply not sent;
|
|
35
39
|
- after a rate-budget wait the send's timeouts are cut again, to what the wait left.
|
|
36
40
|
|
|
41
|
+
A deadline that passes before the request is sent (waiting for an in-flight slot, say) says so too:
|
|
42
|
+
`GET item/3: deadline exceeded after 3s, before attempt 1 was sent`, with `attempts` counting only the sends made.
|
|
43
|
+
|
|
37
44
|
For [`stream()`](streaming.md) the deadline bounds opening the stream (every attempt, backoff and the head checks), not
|
|
38
45
|
reading it: the caller's pace of iteration decides how long the body takes. Wrap the block in `asyncio.timeout` to
|
|
39
46
|
bound the whole body.
|
|
@@ -47,8 +54,18 @@ number replaces the read timeout only: `HttpOptions(timeout=60.0)` keeps the oth
|
|
|
47
54
|
A call overrides them with `timeout=`: a `Timeouts` replaces all four, a number the read timeout alone. A view made with
|
|
48
55
|
`with_options(timeout=...)` does the same for its calls. Every value must be positive.
|
|
49
56
|
|
|
57
|
+
A timeout must fit the deadline it runs under, or it could never take effect: each attempt's timeouts are cut to the
|
|
58
|
+
time left. So a timeout longer than that deadline is refused with `ValueError` where it is set: on the client (against
|
|
59
|
+
the policy's deadline), on a view, or on a call. A bare number is checked as the read timeout, and a `Timeouts` by the
|
|
60
|
+
phases set away from their defaults. Shortening the deadline instead is always allowed; cutting the timeouts is what a
|
|
61
|
+
short deadline is for. To give a slow endpoint a long read timeout, raise the deadline with it:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
reports = client.with_options(timeout=300.0, deadline=600.0)
|
|
65
|
+
```
|
|
66
|
+
|
|
50
67
|
## Choosing values
|
|
51
68
|
|
|
52
|
-
- Keep `read` below the deadline, or a single slow response takes the whole call and leaves no time to retry it.
|
|
69
|
+
- Keep `read` well below the deadline, or a single slow response takes the whole call and leaves no time to retry it.
|
|
53
70
|
- A long export or report endpoint wants a `Route` with a longer deadline rather than a client-wide one.
|
|
54
71
|
- `deadline=None` exists for calls that legitimately run unbounded (a long poll); prefer a generous number.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: An async API-client base for Python
|
|
3
|
+
hide:
|
|
4
|
+
- toc
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<div class="hg-hero" markdown>
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
<div markdown>
|
|
12
|
+
|
|
13
|
+
# headgate
|
|
14
|
+
|
|
15
|
+
**An async API-client base for Python, built on [httpx2](https://pypi.org/project/httpx2/).** Subclass
|
|
16
|
+
`AsyncBaseClient`, declare what each endpoint answers, and every client you build gets the same production behaviour.
|
|
17
|
+
Each endpoint method stays one line.
|
|
18
|
+
|
|
19
|
+
[Get started](#install){ .md-button .md-button--primary }
|
|
20
|
+
[How it compares](comparison.md){ .md-button }
|
|
21
|
+
[:fontawesome-brands-github: GitHub](https://github.com/kelsayed17/headgate){ .md-button }
|
|
22
|
+
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="grid cards" markdown>
|
|
28
|
+
|
|
29
|
+
- :lucide-rotate-cw:{ .lg .hg-icon } **Retries that are safe**
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
Idempotent methods only, or a POST under an idempotency key. `Retry-After` is waited exactly, and a budget stops
|
|
34
|
+
retries multiplying an outage.
|
|
35
|
+
|
|
36
|
+
[:lucide-arrow-right: Retries](retries.md)
|
|
37
|
+
|
|
38
|
+
- :lucide-gauge:{ .lg .hg-icon } **A rate budget that listens**
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
One budget shared by every call, paused by `RateLimit` and `X-RateLimit-*` headers and slowed by pushback.
|
|
43
|
+
|
|
44
|
+
[:lucide-arrow-right: Rate budgets](rate-budgets.md)
|
|
45
|
+
|
|
46
|
+
- :lucide-timer:{ .lg .hg-icon } **Deadlines and a breaker**
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
One deadline bounds every attempt, wait and read. A per-origin circuit breaker stops calls to a host that is down.
|
|
51
|
+
|
|
52
|
+
[:lucide-arrow-right: Deadlines](deadlines.md) · [Circuit breaker](breaker.md)
|
|
53
|
+
|
|
54
|
+
- :lucide-key-round:{ .lg .hg-icon } **Credentials that stay home**
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
Credentials are pinned to their origin, an https-to-http redirect is refused, and an SSRF guard is one option away.
|
|
59
|
+
|
|
60
|
+
[:lucide-arrow-right: Security](security.md)
|
|
61
|
+
|
|
62
|
+
- :lucide-database-zap:{ .lg .hg-icon } **A cache that keeps no secrets**
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
hishel's cache, partitioned by credential, never storing one, and storing only what your reader accepted.
|
|
67
|
+
|
|
68
|
+
[:lucide-arrow-right: Caching](caching.md)
|
|
69
|
+
|
|
70
|
+
- :lucide-activity:{ .lg .hg-icon } **Errors and events that explain**
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
Typed errors that say what happened and whether to retry, typed events, and an OpenTelemetry observer.
|
|
75
|
+
|
|
76
|
+
[:lucide-arrow-right: Errors](errors.md) · [Observability](observability.md)
|
|
77
|
+
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
=== "pip"
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install headgate
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
=== "uv"
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv add headgate
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
headgate needs **Python 3.14** or newer: endpoint paths are [template strings](https://peps.python.org/pep-0750/), so
|
|
95
|
+
each value is escaped into its own path segment by default. It runs on **httpx2**, pydantic's fork of httpx; see
|
|
96
|
+
[why httpx2](comparison.md#why-httpx2).
|
|
97
|
+
|
|
98
|
+
## A real client
|
|
99
|
+
|
|
100
|
+
A client for a few GitHub REST endpoints. The full version, with its tests, is
|
|
101
|
+
[`examples/github_client.py`](https://github.com/kelsayed17/headgate/blob/main/examples/github_client.py).
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from http import HTTPStatus
|
|
105
|
+
|
|
106
|
+
import msgspec
|
|
107
|
+
|
|
108
|
+
from headgate import AsyncBaseClient, Expect, HttpOptions, LinkHeader, Rate, RateBudget, StaticHeaderAuth, nothing
|
|
109
|
+
from headgate.msgspec import decoder
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Repository(msgspec.Struct, frozen=True):
|
|
113
|
+
full_name: str
|
|
114
|
+
stargazers_count: int
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Issue(msgspec.Struct, frozen=True):
|
|
118
|
+
number: int
|
|
119
|
+
title: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
REPOSITORY_OR_NONE = Expect(decoder(Repository)).on(HTTPStatus.NOT_FOUND, nothing)
|
|
123
|
+
ISSUE_PAGES = LinkHeader(items=lambda page: page) # each page is a JSON list of issues
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class GitHub(AsyncBaseClient):
|
|
127
|
+
def __init__(self, token: str) -> None:
|
|
128
|
+
super().__init__(
|
|
129
|
+
base_url="https://api.github.com",
|
|
130
|
+
rate=RateBudget(Rate.per_second(10), max_in_flight=50),
|
|
131
|
+
auth=StaticHeaderAuth({"Authorization": f"Bearer {token}"}),
|
|
132
|
+
http=HttpOptions(headers={"Accept": "application/vnd.github+json"}),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def repository(self, owner: str, name: str) -> Repository | None:
|
|
136
|
+
return await self.get(t"repos/{owner}/{name}", expect=REPOSITORY_OR_NONE)
|
|
137
|
+
|
|
138
|
+
def open_issues(self, owner: str, name: str):
|
|
139
|
+
return self.paginate(t"repos/{owner}/{name}/issues", expect=decoder(list[Issue]), pages=ISSUE_PAGES)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
async with GitHub(token) as github:
|
|
144
|
+
repository = await github.repository("python", "cpython")
|
|
145
|
+
async for issue in github.open_issues("python", "cpython"):
|
|
146
|
+
print(issue.number, issue.title)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Retries, pacing under GitHub's limits, pausing when its hourly limit is spent, following `Link` headers, and keeping
|
|
150
|
+
the token on `api.github.com` all come from the base class.
|
|
151
|
+
|
|
152
|
+
## Where to next
|
|
153
|
+
|
|
154
|
+
- [Compared with others](comparison.md): what headgate does that tenacity, stamina or a hand-rolled httpx client
|
|
155
|
+
does not, and when to pick those instead.
|
|
156
|
+
- [Configuration](configuration.md): every option, and where it lives.
|
|
157
|
+
- [Declared responses](responses.md) and [errors](errors.md): what an endpoint answers, and what it raises.
|
|
158
|
+
- [Testing a client](testing.md): fake networks, instant retries and failure schedules, with nothing private patched.
|
|
@@ -27,6 +27,31 @@ Nothing is fetched until iteration starts, no page is kept once the iteration mo
|
|
|
27
27
|
nothing more, and iterating again starts again from the first page. A page that leads back to a request already made
|
|
28
28
|
(a cursor or next URL that repeats) raises `ClientError` instead of looping forever.
|
|
29
29
|
|
|
30
|
+
## A page budget
|
|
31
|
+
|
|
32
|
+
A walk ends at its last page, but a server bug, an unordered dataset or a too-broad filter can keep naming new pages
|
|
33
|
+
for as long as you keep asking. `limit=PageLimit(max_pages)` bounds the walk:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from headgate import PageLimit, PageLimitError
|
|
37
|
+
|
|
38
|
+
orders = client.paginate("orders", expect=decoder(Page), pages=ORDER_PAGES, limit=PageLimit(50))
|
|
39
|
+
try:
|
|
40
|
+
async for order in orders:
|
|
41
|
+
...
|
|
42
|
+
except PageLimitError as exc:
|
|
43
|
+
print(f"stopped after {exc.pages} pages: the result is incomplete")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **`on_limit="raise"`** (the default) raises `PageLimitError`, a `ClientError`, once the budget is spent with pages
|
|
47
|
+
still to come. The pages within the budget were already yielded; `exc.pages` says how many. A caller cannot mistake
|
|
48
|
+
a cut-short walk for a complete one.
|
|
49
|
+
- **`on_limit="stop"`** ends the walk at the budget instead, for a caller that wants only the first pages:
|
|
50
|
+
`PageLimit(5, on_limit="stop")`.
|
|
51
|
+
- A walk whose last page falls within the budget, or exactly on it, is unaffected.
|
|
52
|
+
|
|
53
|
+
Set the budget where the client calls `paginate`, so every caller of that endpoint gets it.
|
|
54
|
+
|
|
30
55
|
`paginate` sends GETs, so it refuses body options (`json`, `data`, `content`, `files`) with `ValueError`. Its `params=`
|
|
31
56
|
are the first page's query, and a pager's parameter is added to them. `expect=` may be an `Expect`: a page can be a
|
|
32
57
|
declared answer like any other.
|
|
@@ -35,10 +60,14 @@ declared answer like any other.
|
|
|
35
60
|
|
|
36
61
|
| Strategy | The next page is | Last page |
|
|
37
62
|
| --- | --- | --- |
|
|
38
|
-
| `Cursor(items=, next=, param=)` | the same request with `param` set to the cursor `next(page)` returns | `next` returns `None` or `""
|
|
63
|
+
| `Cursor(items=, next=, param=)` | the same request with `param` set to the cursor `next(page)` returns | `next` returns `None` or `""`, or a page with no items |
|
|
39
64
|
| `Offset(items=, limit=, param="offset", start=0)` | the same request with `param` advanced by the items this page held | a page with fewer than `limit` items |
|
|
40
|
-
| `LinkHeader(items=)` | the target of the response's `Link: <...>; rel="next"` (RFC 8288) | no `rel="next"` link |
|
|
41
|
-
| `NextUrl(items=, next=)` | the URL `next(page)` returns, absolute or relative | `next` returns `None` or `""
|
|
65
|
+
| `LinkHeader(items=)` | the target of the response's `Link: <...>; rel="next"` (RFC 8288) | no `rel="next"` link, or a page with no items |
|
|
66
|
+
| `NextUrl(items=, next=)` | the URL `next(page)` returns, absolute or relative | `next` returns `None` or `""`, or a page with no items |
|
|
67
|
+
|
|
68
|
+
A page with no items ends a `Cursor`, `LinkHeader` or `NextUrl` walk even when it links to another: a link that
|
|
69
|
+
advances while returning nothing makes no progress, and following it spends the rate budget for nothing. For an
|
|
70
|
+
endpoint that sends empty pages mid-walk, pass `stop_on_empty=False`.
|
|
42
71
|
|
|
43
72
|
`Offset` sends only the offset; the page size goes in `params=`, under whatever name the endpoint uses (`limit`,
|
|
44
73
|
`per_page`, `page_size`). A next URL from the wire (`LinkHeader`, `NextUrl`) is resolved against the URL that answered
|
|
@@ -28,7 +28,8 @@ under a `threading.Lock` and nothing in it is bound to a loop.
|
|
|
28
28
|
|
|
29
29
|
- **Pacing** is GCRA, the virtual-scheduling form of a leaky bucket. Up to `rate.requests` requests (at least one) go
|
|
30
30
|
out back to back; after that they are `period / requests` seconds apart. Waiters are served in arrival order, and a
|
|
31
|
-
waiter cancelled in the queue
|
|
31
|
+
waiter cancelled in the queue gives its turn back when nobody reserved after it. A call whose turn comes at or after
|
|
32
|
+
its deadline does not queue: it gives the turn back and the attempt is never sent (see [deadlines](deadlines.md)).
|
|
32
33
|
- **`max_in_flight`** caps how many requests are open at once. A request holds its slot from the send until its
|
|
33
34
|
response body is **closed**, not until the head arrives, since the server is still busy with a body that streams.
|
|
34
35
|
Every path that drops a response closes it, so a slot cannot leak.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/* headgate's colours, taken from the logo: deep navy, a working blue and a bright cyan. */
|
|
2
|
+
:root {
|
|
3
|
+
--hg-navy: #011740;
|
|
4
|
+
--hg-blue: #0a82e0;
|
|
5
|
+
--hg-cyan: #1fd6f5;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
[data-md-color-scheme="default"] {
|
|
9
|
+
--md-primary-fg-color: var(--hg-navy);
|
|
10
|
+
--md-primary-fg-color--light: #0b2a63;
|
|
11
|
+
--md-primary-fg-color--dark: #000d26;
|
|
12
|
+
--md-accent-fg-color: var(--hg-blue);
|
|
13
|
+
--md-typeset-a-color: var(--hg-blue);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
[data-md-color-scheme="slate"] {
|
|
17
|
+
--md-primary-fg-color: var(--hg-cyan);
|
|
18
|
+
--md-primary-fg-color--light: #6ae4fa;
|
|
19
|
+
--md-primary-fg-color--dark: #0fb3cf;
|
|
20
|
+
/* Text on the cyan: navy, since white on a light cyan is hard to read. */
|
|
21
|
+
--md-primary-bg-color: var(--hg-navy);
|
|
22
|
+
--md-primary-bg-color--light: rgba(1, 23, 64, 0.7);
|
|
23
|
+
--md-accent-fg-color: var(--hg-cyan);
|
|
24
|
+
--md-typeset-a-color: var(--hg-cyan);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* The landing page's hero: the logo, the name, one sentence and the ways in. */
|
|
28
|
+
.hg-hero {
|
|
29
|
+
display: grid;
|
|
30
|
+
grid-template-columns: 9rem 1fr;
|
|
31
|
+
gap: 1.5rem 2rem;
|
|
32
|
+
align-items: center;
|
|
33
|
+
margin: 1rem 0 2.5rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.hg-hero img {
|
|
37
|
+
width: 9rem;
|
|
38
|
+
height: 9rem;
|
|
39
|
+
border-radius: 1.6rem;
|
|
40
|
+
box-shadow: 0 0.6rem 2rem rgba(10, 130, 224, 0.35);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.md-typeset .hg-hero h1 {
|
|
44
|
+
margin: 0 0 0.4rem;
|
|
45
|
+
font-weight: 800;
|
|
46
|
+
letter-spacing: -0.02em;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hg-hero p {
|
|
50
|
+
margin: 0 0 1rem;
|
|
51
|
+
font-size: 1.05rem;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.md-typeset .hg-hero .md-button {
|
|
55
|
+
margin: 0.2rem 0.4rem 0.2rem 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@media screen and (max-width: 44.9375em) {
|
|
59
|
+
.hg-hero {
|
|
60
|
+
grid-template-columns: 1fr;
|
|
61
|
+
justify-items: center;
|
|
62
|
+
text-align: center;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.hg-hero img {
|
|
66
|
+
width: 6rem;
|
|
67
|
+
height: 6rem;
|
|
68
|
+
border-radius: 1.1rem;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Feature cards: a coloured icon above each title. */
|
|
73
|
+
.md-typeset .grid.cards .hg-icon {
|
|
74
|
+
color: var(--md-accent-fg-color);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Wide tables read better a little smaller, with each row named in bold. */
|
|
78
|
+
.md-typeset table:not([class]) {
|
|
79
|
+
font-size: 0.7rem;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.md-typeset table:not([class]) td:first-child {
|
|
83
|
+
min-width: 8rem;
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* The comparison's last column is headgate's: tinted, so it reads as the answer. */
|
|
88
|
+
.hg-compare table td:last-child,
|
|
89
|
+
.hg-compare table th:last-child {
|
|
90
|
+
background-color: color-mix(in srgb, var(--md-accent-fg-color) 9%, transparent);
|
|
91
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
with nothing private patched. Two seams make that possible:
|
|
5
5
|
|
|
6
6
|
- **the network** is `HttpOptions(transport=...)`. Have the client accept a transport and pass it through, as the
|
|
7
|
-
[examples](
|
|
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
|
|