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