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.
- {headgate-0.3.0 → headgate-0.4.0}/.gitignore +3 -0
- {headgate-0.3.0 → headgate-0.4.0}/CHANGELOG.md +23 -1
- {headgate-0.3.0 → headgate-0.4.0}/PKG-INFO +21 -4
- {headgate-0.3.0 → headgate-0.4.0}/README.md +19 -2
- {headgate-0.3.0 → headgate-0.4.0}/docs/auth.md +1 -1
- headgate-0.4.0/docs/comparison.md +50 -0
- headgate-0.4.0/docs/index.md +158 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/pagination.md +32 -3
- headgate-0.4.0/docs/stylesheets/extra.css +91 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/testing.md +1 -1
- headgate-0.4.0/examples/github_client.py +86 -0
- {headgate-0.3.0 → headgate-0.4.0}/pyproject.toml +3 -2
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/__init__.py +4 -1
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_client.py +7 -2
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/errors.py +15 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/__init__.py +2 -1
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/_pager.py +23 -7
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/_paginated.py +52 -7
- headgate-0.4.0/tests/examples/test_github_client.py +76 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/package/__snapshots__/test_api_surface.ambr +26 -12
- {headgate-0.3.0 → headgate-0.4.0}/tests/pagination/test_paginate.py +144 -1
- {headgate-0.3.0 → headgate-0.4.0}/LICENSE +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/breaker.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/caching.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/configuration.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/deadlines.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/design.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/errors.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/mutation-testing.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/observability.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/rate-budgets.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/responses.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/retries.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/security.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/docs/streaming.md +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/examples/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/examples/boundary.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/examples/orders_client.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/examples/quickstart.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/examples/series_client.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_attempts.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_auth.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_body.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_codec.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_context.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_events.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_origin.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_retry_after.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_seams.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_switch.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_core/_time.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_curl.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_deprecation.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_failures.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_middleware.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_options.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_redirects.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_request_body.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_responses.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_stack.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_url.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_user_agent.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/_view.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_breaker.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_policy.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_registry.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/breaker/_scope.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_conditional.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_credentials.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_policy.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/cache/_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/curl.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/events.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/health.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/msgspec.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/otel.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pagination/_link.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/policy.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/py.typed +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/pydantic.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_budget.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_budgets.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_feedback.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_fields.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_inflight.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_rate.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/ratelimit/_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_budget.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_classify.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_policy.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_tracking.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/retry/_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_addresses.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_credentials.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_downgrade.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/security/_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/streaming/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/streaming/_stream.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_breakers.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_recording.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_time.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/src/headgate/testing/faults.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/auth/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_cached_token_auth.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_credential_stampers.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_flows_are_uniform.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_header_and_query_auth.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/auth/test_mint_failures.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_charging.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_integration.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_keying.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_breaker_state_machine.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/breaker/test_registry.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/_helpers.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_conditional.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_credentials.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_events.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_extension_passthrough.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_fallback.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_filters.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_held_bodies.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_integration.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_shared_downloads.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/cache/test_store_on_accept.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_call_options.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_client.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_deadline.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_error_details.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_error_messages.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_events.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_failure_policy.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_invalid_url.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_middleware.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_options.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_policy.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_redirect_identity.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_response_size_cap.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_retry_opt_out.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_timeouts_fit_deadline.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/client/test_verbs.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/conftest.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/core/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_context.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_events.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_origin.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/core/test_retry_after.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_errors.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_event_stream.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_msgspec.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_pydantic.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/decoding/test_responses.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/errors/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/errors/__snapshots__/test_error_messages_snapshot.ambr +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/errors/test_error_messages_snapshot.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/examples/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_docs.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_orders_client.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_quickstart.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/examples/test_series_client.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/_support.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_breaker_gate.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_cache_and_auths.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_deadline.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_destinations.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_events.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_hedging.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_pagination_credentials.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_replayable_body.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_retry_and_rate.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/integration/test_views.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/__snapshots__/test_snapshots.ambr +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/_support.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_accept.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_deprecation.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_otel.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_otel_isolation.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_snapshots.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/observe/test_with_options.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/package/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_api_surface.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_boundaries.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_contracts.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_import_isolation.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/package/test_metadata.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/pagination/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/pagination/test_link_header.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/performance/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/performance/test_memory.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/properties/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/properties/test_policy_properties.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/properties/test_url_properties.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_budget.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_client_feedback.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_feedback.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_in_flight.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_rate.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/ratelimit/test_rate_limit_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_budget_and_classify.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_client_retries.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_faults.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_parking_and_hedging.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_policy.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_properties.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_tracking.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/retry/test_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/_servers.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/_web.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/certs/ca.pem +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/certs/server.pem +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_addresses.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_credential_origins.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_origin_properties.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_pinned_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_redirect_hardening.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/security/test_ssrf_guard.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/streaming/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/streaming/test_request_bodies.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/streaming/test_stream.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/breakers.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/budgets.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/clients.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/events.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/responses.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/tokens.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/support/wire.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/testing/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_breakers.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_cassettes.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_fake_transport.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_recording.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/testing/test_time.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/transport/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/transport/test_curl.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/transport/test_health.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/urls/__init__.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_empty_segment.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_path_escaping.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_route_template.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_sanitize_url.py +0 -0
- {headgate-0.3.0 → headgate-0.4.0}/tests/urls/test_secret_query_params.py +0 -0
|
@@ -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.
|
|
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
|
+
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
|
|
@@ -43,6 +43,13 @@ Description-Content-Type: text/markdown
|
|
|
43
43
|
|
|
44
44
|
# headgate
|
|
45
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
|
+
|
|
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
|
|
58
|
-
|
|
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
|
+
[](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
|
+
|
|
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
|
|
19
|
-
|
|
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`](
|
|
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
|
+

|
|
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](
|
|
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.
|
|
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.
|
|
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",
|