Skip to content

docs(schemas): describe multi-value handling on forward_client_headers - #1138

Merged
jarvis9443 merged 4 commits into
mainfrom
docs/forward-client-headers-first-value
Sep 7, 2026
Merged

docs(schemas): describe multi-value handling on forward_client_headers#1138
jarvis9443 merged 4 commits into
mainfrom
docs/forward-client-headers-first-value

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Problem

forward_client_headers lets an operator name inbound client headers that must reach the upstream. It appears on four resources — provider_key, mcp_server, a2a_agent and passthrough_route — and what it does with a header the caller sent more than once was described nowhere a user reads. Release QA hit this black box: a caller sending x-multi: one and x-multi: two with forward_client_headers: ["x-multi"] saw the upstream agent receive x-multi: one alone, with nothing in the field's description to say so.

The two paths differ, and this describes the difference rather than reconciling it

Auditing the whole family turned up a second, equally undocumented fact: the four surfaces do not agree, because two different code paths implement the forwarding.

  • provider_key, mcp_server and a2a_agent resolve the forwarded set through aisix_core::resolve_forwarded_client_headers, which walks HeaderMap::keys() and reads client.get(name). keys() yields each name once and get() returns the first value, so a repeated header forwards its first value only.
  • passthrough_route builds its outbound request in crates/aisix-proxy/src/passthrough_route.rs by iterating the inbound HeaderMap directly. &HeaderMap yields one pair per value, and RequestBuilder::header appends rather than replaces, so every value is preserved.

Confirmed with a throwaway probe over the same two-value HeaderMap: the resolver produced ["x-multi=one"], the passthrough iteration produced ["x-multi=one", "x-multi=two"].

This PR describes each surface's actual behavior and deliberately does not reconcile the two paths. Making them agree would change forwarding behavior on a surface released in v1.0.0, which is a separate decision and not one a documentation fix should take. The divergence below is therefore intentional and recorded, not an oversight for a later reader to "clean up". No forwarding behavior changes here.

What changed

Four field descriptions, written as doc comments on the models under crates/aisix-core/src/models/ and regenerated into schemas/resources/ with cargo run -p aisix-core --bin dump-schema. The generated JSON was not hand-edited.

provider_key, mcp_server, a2a_agent:

A header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split cookie across several header fields, and only the first of them is forwarded.

passthrough_route:

A header the caller sends more than once is forwarded with every value preserved.

The cookie sentence names the case an operator is most likely to meet this in rather than leaving it as an abstraction. The gateway terminates inbound HTTP/2 (ALPN h2, plus h2c preface sniffing), an HTTP/2 client may legitimately split cookie across several header fields, and the h2 crate does not reassemble them — so a forwarded cookie from such a caller loses all but the first field on the three resolver-backed surfaces. That is a known limitation, stated so an operator can see it before relying on it, not something changed here.

The same two strings, mapped to the same surfaces, are going into openapi/cp-admin.yaml on the control-plane side, so the two planes describe each surface identically.

Testing

No behavioural test applies: nothing about runtime behaviour changes. The four edits are doc comments, and the only other change is the schema JSON regenerated from them, so there is no state in which this PR makes a request behave differently — a fail-before / pass-after test would have nothing to assert against.

What was verified instead:

  • The regenerated JSON differs only in these four descriptions — git diff -U0 schemas/ is exactly four changed lines, one description per surface, each with the new paragraph inserted and the rest byte-identical.
  • Each surface carries its intended sentence verbatim and exactly once, checked by parsing the committed schema JSON and comparing the inserted paragraph against the expected string.
  • The schema-drift CI gate reproduced locally: re-running dump-schema after the commit leaves git diff --exit-code schemas/ clean.
  • cargo test -p aisix-core --lib models:: — 467 passed, and cargo fmt --check clean.

Two tests were added, one per rule, because the descriptions above are now a public contract on two planes and nothing kept them true. Every fixture in the forwarded_headers test module is built with HeaderMap::insert, which cannot express a header the caller sent twice, so no existing test went red if the resolver stopped collapsing to the first value; the passthrough guarantee had no coverage either. Each new test was verified to fail without the behavior it pins — switching the resolver to per-value iteration turns the first into ["cookie", "cookie"], and switching the passthrough loop to keys() + get() drops the second value from the other. The passthrough case deliberately repeats a header the ProviderKey strips, so it covers the strip-override branch rather than the plain default-forward one.

One comment was narrowed as a consequence: above the credential injection in passthrough_route.rs, "the wire stays single-valued" was true of what that block does, but reads as a claim about the whole request once the field description promises the opposite. It now says which of the two it means.

schemas/resources-lenient/ arrived on main from #1137 after this branch was cut. Those files generate from the same doc comments, so main was merged in and dump-schema re-run; the four extra changed lines are the same four descriptions in the lenient copies.

Summary by CodeRabbit

  • Bug Fixes

    • Corrected header forwarding behavior for passthrough routes so repeated caller-provided headers preserve all permitted values.
    • Clarified credential injection behavior when caller-provided headers are present.
    • Added regression coverage for repeated and HTTP/2-split cookie headers.
  • Documentation

    • Clarified that configured client-header forwarding uses only the first value for duplicate headers, while passthrough forwarding preserves every value.

`forward_client_headers` appears on four resources, and its handling of a
header the caller sent more than once was described nowhere a user reads.
The two code paths behind the field also differ, and the difference was
equally undocumented:

- `provider_key`, `mcp_server` and `a2a_agent` resolve the forwarded set
  through `resolve_forwarded_client_headers`, which walks `HeaderMap::keys()`
  and takes `get(name)` — the first value only.
- `passthrough_route` builds its outbound request by iterating the inbound
  `HeaderMap` directly, which yields one pair per value, and appends each —
  every value is preserved.

Each field description now states its own surface's behavior, so all four
are accurate rather than uniform. The `provider_key` / `mcp_server` /
`a2a_agent` text also names the case an operator is most likely to meet it
in: an HTTP/2 caller may split `cookie` across several header fields, and
only the first is forwarded.

Doc comments only, plus the schemas regenerated from them with
`cargo run -p aisix-core --bin dump-schema`. No forwarding behavior
changes.
@coderabbitai

coderabbitai Bot commented Sep 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: Essentials

Run ID: d60168b6-8165-4dc5-8089-f9d306f4ea38

📥 Commits

Reviewing files that changed from the base of the PR and between 9eabbec and 816568d.

📒 Files selected for processing (14)
  • crates/aisix-core/src/forwarded_headers.rs
  • crates/aisix-core/src/models/a2a_agent.rs
  • crates/aisix-core/src/models/mcp_server.rs
  • crates/aisix-core/src/models/passthrough_route.rs
  • crates/aisix-core/src/models/provider_key.rs
  • crates/aisix-proxy/src/passthrough_route.rs
  • schemas/resources-lenient/a2a_agent.schema.json
  • schemas/resources-lenient/mcp_server.schema.json
  • schemas/resources-lenient/passthrough_route.schema.json
  • schemas/resources-lenient/provider_key.schema.json
  • schemas/resources/a2a_agent.schema.json
  • schemas/resources/mcp_server.schema.json
  • schemas/resources/passthrough_route.schema.json
  • schemas/resources/provider_key.schema.json

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.


📝 Walkthrough

Walkthrough

The change documents repeated-header forwarding semantics, adds regression coverage for first-value cookie forwarding, and verifies that passthrough routes preserve repeated caller header values.

Changes

Forwarded header semantics

Layer / File(s) Summary
Core forwarding contract
crates/aisix-core/src/forwarded_headers.rs, crates/aisix-core/src/models/*
Core documentation defines first-value forwarding for duplicate headers, including split HTTP/2 cookies. A regression test verifies this behavior for repeated cookie headers.
Passthrough forwarding behavior
crates/aisix-proxy/src/passthrough_route.rs, crates/aisix-core/src/models/passthrough_route.rs
Passthrough documentation clarifies credential injection precedence and preservation of repeated caller header values. Integration coverage verifies repeated headers through strip-override forwarding.
Schema documentation alignment
schemas/resources*/{a2a_agent,mcp_server,passthrough_route,provider_key}.schema.json
Resource schemas document first-value forwarding or full-value preservation according to each resource’s behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 81656

This clarifies repeated-header forwarding semantics across resource types and adds regression coverage without changing runtime behavior; no current merge-readiness risk remains.

Suggested reviewers: moonming

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Blocking: the PR does not add E2E coverage for the new multi-value forwarding contract. The resolver test directly calls resolve_forwarded_client_headers, so it is a unit test. The passthrough test … Add E2E scenarios to the existing forward-client-headers suite. Send repeated header fields through the real gateway and inspect all received header occurrences at the upstream. Cover at least one request for each resolver-backed surface …
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed No security failure condition was introduced. The diff against origin/main changes model and schema documentation, adds tests, and changes one production comment. The forwarding loop and resolver beha…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the documentation changes for multi-value handling in forward_client_headers.
Full details: E2e Test Quality Review

Explanation

Blocking: the PR does not add E2E coverage for the new multi-value forwarding contract. The resolver test directly calls resolve_forwarded_client_headers, so it is a unit test. The passthrough test is inside #[cfg(test)] mod tests and uses build_app(...).oneshot with a wiremock::MockServer; it covers only that route. The repository E2E case tests/e2e/src/cases/forward-client-headers-e2e.test.ts was not changed and does not send repeated inbound values or assert the new first-value/all-values behavior. The documented resolver behavior therefore has no full API-to-upstream E2E check across provider_key, mcp_server, and a2a_agent. The HTTP/2 split-cookie case is also untested end to end. The added test names, comments, and assertions are otherwise clear, and the changes stay within the stated documentation and characterization-test scope.

Resolution

Add E2E scenarios to the existing forward-client-headers suite. Send repeated header fields through the real gateway and inspect all received header occurrences at the upstream. Cover at least one request for each resolver-backed surface (provider_key, mcp_server, and a2a_agent) and a passthrough_route request that exercises strip override. Assert first-value-only behavior on the resolver-backed surfaces and preservation of every value on the passthrough surface. Add an HTTP/2 split-cookie scenario, or document and test the supported transport boundary if the E2E harness cannot create split fields.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/forward-client-headers-first-value

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

Copilot AI 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.

🟢 Approval recommended

The changes are documentation-only and accurately describe the existing, verified forwarding behavior on each surface.

Pull request overview

Documents how forward_client_headers behaves when the inbound request contains repeated headers, making an existing but previously undocumented difference between resolver-backed resources and passthrough_route explicit to operators.

Changes:

  • Clarify that provider_key, mcp_server, and a2a_agent forward only the first value of a repeated inbound header (resolver-backed behavior).
  • Clarify that passthrough_route preserves all values for repeated inbound headers (direct header iteration behavior).
  • Regenerate the published resource schemas to reflect the updated doc comments.
File summaries
File Description
schemas/resources/provider_key.schema.json Updates forward_client_headers description to state repeated headers forward first value only.
schemas/resources/mcp_server.schema.json Updates forward_client_headers description to state repeated headers forward first value only.
schemas/resources/a2a_agent.schema.json Updates forward_client_headers description to state repeated headers forward first value only.
schemas/resources/passthrough_route.schema.json Updates forward_client_headers description to state repeated headers preserve all values.
crates/aisix-core/src/models/provider_key.rs Adds the repeated-header forwarding semantics to the model doc comment.
crates/aisix-core/src/models/mcp_server.rs Adds the repeated-header forwarding semantics to the model doc comment.
crates/aisix-core/src/models/a2a_agent.rs Adds the repeated-header forwarding semantics to the model doc comment.
crates/aisix-core/src/models/passthrough_route.rs Adds the repeated-header forwarding semantics to the model doc comment.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The previous commit turned a code comment into a public contract on two
planes, with nothing keeping it true. Every fixture in the
`forwarded_headers` tests is built with `HeaderMap::insert` via `map()`,
which cannot express a header the caller sent twice, so no test went red
if `resolve_forwarded_client_headers` stopped collapsing to the first
value — and none covered the passthrough path's opposite guarantee.

Two tests, one per rule, each verified to fail without the behavior it
pins: switching the resolver to per-value iteration turns the first into
`["cookie", "cookie"]`, and switching the passthrough loop to
`keys()` + `get()` drops `second` from the second.

The passthrough case deliberately repeats a header the ProviderKey
strips, so it covers the strip-override branch rather than the plain
default-forward one.

Also narrows the injection comment above the credential inject: "the
wire stays single-valued" was true of what that block does and is now
easy to read as a claim about the whole request, which the passthrough
description explicitly contradicts.
#1137 landed `schemas/resources-lenient/` on main after this branch was
cut. Those files are generated from the same doc comments this branch
edits, so the merge left them carrying the pre-change descriptions —
which the drift gate and the two characterization tests that compare the
published set against what the loader compiles both catch.

Regenerated with `cargo run -p aisix-core --bin dump-schema`. The four
changed lines are the same four descriptions, in the lenient copies.
@jarvis9443
jarvis9443 merged commit b8f9c37 into main Sep 7, 2026
15 checks passed
@jarvis9443
jarvis9443 deleted the docs/forward-client-headers-first-value branch September 7, 2026 04:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants