Skip to content

fix(gateway): preserve HTTPX event hooks#6327

Merged
dsfaccini merged 7 commits into
pydantic:mainfrom
VectorPeak:fix-gateway-preserve-httpx-hooks
Jul 8, 2026
Merged

fix(gateway): preserve HTTPX event hooks#6327
dsfaccini merged 7 commits into
pydantic:mainfrom
VectorPeak:fix-gateway-preserve-httpx-hooks

Conversation

@VectorPeak

@VectorPeak VectorPeak commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6326

Fixes a Gateway provider edge case where passing a pre-configured httpx.AsyncClient could silently drop the caller's existing event hooks.

What Problem This Solves

gateway_provider(..., http_client=client) currently replaces the client's full event_hooks mapping with the Gateway request hook.

That means a caller-provided client loses any existing request hooks and all response hooks. These hooks are commonly used for tracing, metrics, request capture, auditing, custom diagnostics, or auth-adjacent behavior. The request can still succeed, so the failure mode is easy to miss: surrounding HTTP client behavior disappears after constructing the Gateway provider.

A minimal local reproduction before this change showed:

before_request_hooks= ['existing_request_hook']
before_response_hooks= ['existing_response_hook']
after_request_hooks= ['_hook']
after_response_hooks= []
request_hook_preserved= False
response_hook_preserved= False

Change

The Gateway provider now adds its request hook without replacing caller-provided HTTPX hooks.

For repeated gateway_provider(..., http_client=same_client, ...) calls, the Gateway-owned hook is replaced rather than duplicated. This keeps user hooks intact while ensuring the latest Gateway provider configuration owns the Gateway authorization hook.

Bedrock remains unchanged because that Gateway path does not use HTTPX.

Evidence

Focused local validation:

uv run --extra groq --extra bedrock pytest tests/providers/test_gateway.py::test_init_with_http_client tests/providers/test_gateway.py::test_init_with_http_client_preserves_existing_event_hooks tests/providers/test_gateway.py::test_init_with_http_client_replaces_existing_gateway_hook -q -rs

3 passed

Broader non-live Gateway subset:

uv run --extra groq --extra bedrock pytest tests/providers/test_gateway.py -k "init_with_http_client or init_with_base_url or gateway_provider_unknown or routing_group" -q -rs

10 passed, 21 deselected

Static checks:

uv run ruff format --check pydantic_ai_slim/pydantic_ai/providers/gateway.py tests/providers/test_gateway.py
uv run ruff check pydantic_ai_slim/pydantic_ai/providers/gateway.py tests/providers/test_gateway.py
PYRIGHT_PYTHON_IGNORE_WARNINGS=1 uv run pyright pydantic_ai_slim/pydantic_ai/providers/gateway.py tests/providers/test_gateway.py

2 files already formatted
All checks passed!
0 errors, 0 warnings, 0 informations

Possible call chain / impact

user-created httpx.AsyncClient(event_hooks=...)
  -> gateway_provider(..., http_client=client)
  -> Gateway adds auth/trace request hook
  -> existing caller hooks should remain available

This affects the shared HTTPX Gateway setup used by OpenAI, Groq, Anthropic, and Google Cloud providers. It does not affect Bedrock, which returns before HTTPX client setup.

Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • PR title is fit for the release changelog.

Review in cubic

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
@github-actions github-actions Bot added size: S Small PR (≤100 weighted lines) bug Report that something isn't working, or PR implementing a fix labels Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c820bb1e-7f69-4c69-9510-5a44ffcbeb64

📥 Commits

Reviewing files that changed from the base of the PR and between 832f4cb and 35c589b.

📒 Files selected for processing (2)
  • pydantic_ai_slim/pydantic_ai/providers/gateway.py
  • tests/providers/test_gateway.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • pydantic/logfire (manual)
  • pydantic/pydantic-ai (manual)
  • pydantic/pydantic (auto-detected)
🚧 Files skipped from review as they are similar to previous changes (2)
  • pydantic_ai_slim/pydantic_ai/providers/gateway.py
  • tests/providers/test_gateway.py

📝 Walkthrough

Walkthrough

The gateway provider now preserves caller-provided httpx.AsyncClient event hooks instead of replacing them. It uses a typed internal request-hook class plus an _add_request_hook helper to keep existing request and response hooks, remove any prior gateway hook, and append the current one. The internal client factory uses the same helper. Two async tests cover hook preservation, hook replacement on client reuse, and updated type-checker ignore directives.

Sequence Diagram(s)

Included in the hidden review stack artifact above.

Related Issues: #6326

Suggested labels: bug, providers

Suggested reviewers: DouweM, samuelcolvin

🐰 A hook that once would sweep hooks clean,
Now plays nice with the ones unseen,
Marker in paw, it checks with care,
Old hooks stay put, new ones share the air. 🥕

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: preserving HTTPX event hooks in the Gateway provider.
Description check ✅ Passed The description covers the issue, problem, fix, evidence, impact, and checklist, with only a minor template mismatch on "Fixes" vs "Closes".
Linked Issues check ✅ Passed The changes match #6326 by preserving existing request/response hooks and replacing the Gateway hook when the same client is reused.
Out of Scope Changes check ✅ Passed The diff stays focused on the Gateway hook fix and tests, with no meaningful unrelated code changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/providers/test_gateway.py (1)

75-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a note on why these aren't VCR tests.

Both new tests pin exact internal hook ordering/dedup behavior by manually invoking event_hooks['request'] on a request object, without any real HTTP call — cassette playback wouldn't exercise or catch regressions here. As per coding guidelines, "each unit test should explain why it is not (or cannot be) a VCR test, especially when it pins internal behavior... that cassettes would not reliably catch."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/providers/test_gateway.py` around lines 75 - 119, Add a brief note in
both gateway provider tests explaining why they are not VCR tests: they validate
internal httpx event hook preservation/replacement behavior by directly
inspecting and manually invoking http_client.event_hooks rather than making a
real HTTP request. Place the note near
test_init_with_http_client_preserves_existing_event_hooks and
test_init_with_http_client_replaces_existing_gateway_hook so it is clear that
cassette playback would not exercise these hook-ordering and dedup semantics.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/providers/test_gateway.py`:
- Around line 75-119: Add a brief note in both gateway provider tests explaining
why they are not VCR tests: they validate internal httpx event hook
preservation/replacement behavior by directly inspecting and manually invoking
http_client.event_hooks rather than making a real HTTP request. Place the note
near test_init_with_http_client_preserves_existing_event_hooks and
test_init_with_http_client_replaces_existing_gateway_hook so it is clear that
cassette playback would not exercise these hook-ordering and dedup semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5c210e4f-5202-4c39-a637-1703a82b3b3d

📥 Commits

Reviewing files that changed from the base of the PR and between 515c894 and 7c4f53f.

📒 Files selected for processing (2)
  • pydantic_ai_slim/pydantic_ai/providers/gateway.py
  • tests/providers/test_gateway.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • pydantic/logfire (manual)
  • pydantic/pydantic-ai (manual)
  • pydantic/pydantic (auto-detected)

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
@dsfaccini

dsfaccini commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

David's AICA here: 🔍 Local review suite (automated, pre-merge)

Wave Status Blocking Required (do) Warnings (defer/skip)
1 · code-correctness ✅ closed 0 0 open (3 resolved) 0
2 · tests/cassettes/docs ✅ closed 0 0 0
3 · cross-cutting/siblings ✅ closed 0 0 0

Verdict: ✅ PASS — all 3 waves passed, 0 blocking, 0 open. The core fix (merge gateway hook into event_hooks['request'] instead of clobbering the mapping; preserve caller request + response hooks; dedup on repeat calls) was independently verified: reproduced the clobber without the fix, confirmed preservation + idempotency with it, and cross-checked against httpx 0.28.1's event_hooks getter/setter semantics. Scope confirmed unique to gateway.py (grep, two signals) — no sibling provider shares the pattern.
Reviewed at 2026-07-07 · HEAD 35c589bdf

✅ 3 required — RESOLVED

1. String-marker setattr/getattr hook identity replaced with a typed callable class · pydantic_ai_slim/pydantic_ai/providers/gateway.py · _GatewayRequestHook / _add_request_hook · resolved in f950f2c27
The string marker (setattr(_hook, _GATEWAY_REQUEST_HOOK, True) + getattr-based dedup) is gone. The gateway hook is now a typed callable class _GatewayRequestHook (with __call__), and _add_request_hook dedups our own hook via isinstance(existing_hook, _GatewayRequestHook) — statically type-checkable and rename-safe. Caller-supplied hooks are always preserved (no probing needed). This follows the precedent DouweM set in the preceding gateway PR (#4421, r2880477246, r2886498557). Verified: pytest tests/providers/test_gateway.py (31 passed), targeted pyright (0 errors), and a preservation+idempotency probe (two gateway_provider calls on one client → exactly one _GatewayRequestHook, caller request+response hooks kept).

A follow-up commit (832f4cb6e) closed a coverage gap in the PR's own preservation test: test_init_with_http_client_preserves_existing_event_hooks registered a caller response hook and asserted it was present in event_hooks['response'] but never invoked it (its body was uncovered → 100% gate failure). The test now also runs the preserved response hooks against a dummy httpx.Response and asserts the header is set — covering the line and genuinely verifying response-hook preservation, not just presence.

2. Bare # type: ignore → specific # pyright: ignore[reportPrivateUsage] · tests/providers/test_gateway.py · resolved in 35c589bdf
Per the repo convention (never bare # type: ignore; suppress with # pyright: ignore[specificErrorCode]), all ten private-access suppressions in the file were converted to # pyright: ignore[reportPrivateUsage] — including the pre-existing # type: ignore[reportPrivateUsage] lines, which used the # type: ignore form rather than # pyright: ignore. Pyright clean, 31 tests pass.

3. event_hooks.get('request', []) instead of setdefault · pydantic_ai_slim/pydantic_ai/providers/gateway.py · _add_request_hook · resolved in 35c589bdf
_add_request_hook reads the existing request hooks then unconditionally reassigns event_hooks['request'], so setdefault's insert-empty-list side effect was redundant. Switched to .get('request', []) to express the read-only intent. Behavior identical; pyright clean, 31 tests pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
pydantic_ai_slim/pydantic_ai/providers/gateway.py (1)

225-225: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten headers to dict[str, str]. inject() only writes string header values here, so Any is unnecessary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pydantic_ai_slim/pydantic_ai/providers/gateway.py` at line 225, The `headers`
variable in `inject()` is typed too loosely as `dict[str, Any]` even though only
string header values are written. Update the `headers` annotation in
`gateway.py` to `dict[str, str]` and keep the rest of the injection logic
unchanged, using the `inject()` function as the location to make the type more
precise.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pydantic_ai_slim/pydantic_ai/providers/gateway.py`:
- Line 225: The `headers` variable in `inject()` is typed too loosely as
`dict[str, Any]` even though only string header values are written. Update the
`headers` annotation in `gateway.py` to `dict[str, str]` and keep the rest of
the injection logic unchanged, using the `inject()` function as the location to
make the type more precise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 602c5ecc-0aa9-4261-85f4-07197dac609d

📥 Commits

Reviewing files that changed from the base of the PR and between a3c4af7 and f950f2c.

📒 Files selected for processing (1)
  • pydantic_ai_slim/pydantic_ai/providers/gateway.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • pydantic/logfire (manual)
  • pydantic/pydantic-ai (manual)
  • pydantic/pydantic (auto-detected)

@dsfaccini
dsfaccini merged commit 90ecac3 into pydantic:main Jul 8, 2026
62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Report that something isn't working, or PR implementing a fix size: S Small PR (≤100 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gateway provider overwrites existing HTTPX event hooks

2 participants