Skip to content

[TT-17921] Improve header handling for GraphQL-based APIs - #8602

Merged
pvormste merged 11 commits into
masterfrom
TT-17921
Aug 21, 2026
Merged

[TT-17921] Improve header handling for GraphQL-based APIs#8602
pvormste merged 11 commits into
masterfrom
TT-17921

Conversation

@pvormste

@pvormste pvormste commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Values that belong to a single request — the round tripper, the resolved upstream headers, the
upstream response — were being written into, or keyed on, state that outlives the request: the
shared per-API http.Client, the plan cache, and the upstream connection-reuse key. Under
concurrency the result was not deterministic: which request's values a fetch used depended on
timing.

This makes that state request-scoped, and fixes the header handling that depended on it. Seven
commits, each independently revertable.

internal/graphengine goes from 22 to 76 test functions (273 including subtests), green under
-race -count=10.

Behavioural detail and reproduction steps are on the ticket.

What changed

scope transports to requests

reverseProxyPreHandlerV1/V2.PreHandle assigned params.RoundTripper onto the per-API
httpClient.Transport on every request, so concurrent requests overwrote each other's. The round
tripper and headers config now travel in the request context
(SetGraphQLEngineTransportContextValue), and GraphQLEngineTransport.RoundTrip builds a
per-request view from them, falling back to the client's own transport.

keep subscriptions alive, isolate proxy-only responses

Three defects, two of them introduced by the commit above:

  • WebSocket subscriptions never delivered. The handovers passed
    WithContext(params.OutRequest.Context()), but net/http calls w.cancelCtx() as soon as
    ServeHTTP returns — before the hijacked check in conn.serve — and the gateway returns as soon
    as the connection is hijacked. The subscription context was cancelled before anything was sent.
    subscriptionRequestContext roots the subscription at the long-lived engine spec context while
    carrying the opening request's transport values across. EngineV1 passes no context: its legacy
    data sources drop it, so inheriting the request's cancellation could only break the subscription.
  • handleProxyOnly rewrote the outgoing method, turning a proxy-only upstream WebSocket
    handshake into a POST, which WebSocket servers reject. Handshakes now stay GET.
  • GraphQLProxyOnlyContextValues.upstreamResponse was unsynchronised. The transport writes it
    on the resolver's trigger goroutine while the engine reads it (~1 in 20 under -race). Now
    behind a mutex.

cover the request isolation matrix

credential_isolation_test.go: proxy-only, UDG and supergraph × HTTP, SSE, graphql-ws and
graphql-transport-ws × EngineV2 and EngineV3. Each test drives more than one request through one
engine and asserts what each upstream connection actually received, with negative controls
(strip_auth_data, a custom auth header name, a request that sets no header after one that did),
20-request concurrent bursts, a per-request round tripper test that shared mutable transport state
cannot pass, and the client WebSocket-upgrade path that the regression above had slipped through.

move to the fixed graphql-go-tools, cover the cold path

Both module pins move to the library branch; coder/websocket becomes a direct dependency. Two
races are fixed upstream — v1 Schema.Hash writing s.hash on first use, and v2
ResolveGraphQLResponse writing Info on the cached plan — so the cold-start concurrent burst
becomes permanent coverage instead of something a warm-up had to work around.

resolve dynamic upstream headers before the fetch is keyed

The v1 header modifier merged Tyk's additional headers and stopped, leaving tykVariableReplacer
injected but never read, so $tyk_context.* in an upstream header reached the fetch unresolved on
config version 2. That matters for more than the value that arrives upstream: the library applies
the modifier before it derives the key that groups equivalent subscriptions onto one upstream
connection (connectionKey in the v1 subscription client, UniqueRequestID in the v2 resolver).
An unresolved template is the same string for every request, so requests that should have been
distinct were treated as equivalent and pooled together.

Resolving there is only correct now that the library moved header modification to the execution
phase. TT-14357 (adca34d46) had removed it because the modifier ran at plan post-process time and
the result was cached into the plan, and added variableReplaceRoundTripper as the workaround.
That wrapper is retired here: it ran after the grouping key was derived, it collapsed multi-value
headers via Get/Set on the way to the wire, and it expanded variables in header values that did
not come from the API definition. Its one remaining job, request_headers_rewrite, is resolved
once where the rules are built, from configuration only.

restore the gateway round tripper for EngineV1 fetches

Regression from cf506a971, caught while verifying the above:
TestGraphQL_InternalDataSource/graphql_engine_v1 fails at the branch tip and passes at the base —
a config version 1 API with a tyk:// data source answers 500. The transport assignment that
commit removed was also the only thing giving EngineV1 the gateway round tripper, and only that
round tripper can route tyk://. Since EngineV1's data sources drop the execution context, the
round tripper is handed over as a mutex-guarded transport-level fallback, used only when a request
carries none of its own. EngineV2 and EngineV3 stay fully request-scoped.

That fallback is the one piece of transport state shared between requests, and it is only sound
because the round tripper the gateway hands over no longer carries request-specific state — the
wrapper retired in 4d67f9af4 did. The two commits must not be reverted independently; both
sides carry comments saying so.

forward a consumer header to the upstream once

Two writers put the consumer's headers on the upstream request and neither knew about the other:
propagateAuthHeaders adds them to the fetch input when strip_auth_data is off, and
setProxyOnlyHeaders then forwards every consumer header again with Header.Add. With
use_immutable_headers off nothing removed the first copy, so a header present in both arrived
duplicated. It went unnoticed because the wrapper retired in 4d67f9af4 had been collapsing
repeated values with Get/Set since before the second writer was added.

setProxyOnlyHeaders now skips a value the engine already placed there, compared against a
snapshot taken before forwarding so a consumer that genuinely sent the same value twice keeps both.
It also hoists the use_immutable_headers delete out of the value loop, where it removed the value
added on the previous pass and left a multi-value consumer header holding only its last value.

Behaviour changes worth a reviewer's sign-off

  1. An API reload or release now ends that API's subscriptions. Previously they ran on
    context.Background() and outlived the reload.
  2. No header value is resolved twice on config version 2 or 3-preview. Values that do not come
    from the API definition are no longer expanded, and a value that resolved into another template
    token no longer gets a second pass.
  3. An exact overlap between a configured request_headers value and a consumer value now reaches
    the upstream once
    , not twice. Differing values still both go upstream — TT-11990 / TT-12190
    semantics are unchanged.
  4. Multi-value headers survive to the upstream. Accept-Encoding: gzip, deflate, br used to
    arrive as gzip alone.

Ticket Details

TT-17921
Status Merge
Summary Federated supergraph reuses first caller’s JWT across users and can share HTTP responses via single-flight

Generated at: 2026-08-21 09:06:43

@pvormste
pvormste requested a review from kofoworola August 18, 2026 15:34
@github-actions

Copy link
Copy Markdown
Contributor

🎯 Recommended Merge Targets

Based on JIRA ticket TT-17921: Federated supergraph reuses first caller’s JWT across users and can share HTTP responses via single-flight

Fix Version: Tyk 5.15.0

Required:

  • release-5.15.0 - Exact version branch for Tyk 5.15.0 - specific patch release
  • master - Main development branch - ensures fix is in all future releases

Recommended:

  • release-5.15 - Minor version branch for 5.15.x releases

Fix Version: Tyk 5.13.2

Required:

  • release-5.13.2 - Exact version branch for Tyk 5.13.2 - specific patch release
  • release-5.13 - Minor version branch for 5.13.x patches - required for creating Tyk 5.13.2
  • master - Main development branch - ensures fix is in all future releases

📋 Workflow

  1. Merge this PR to master first

  2. Cherry-pick to release branches by commenting on the merged PR:

    • /release to release-5.15.0
    • /release to release-5.15
    • /release to release-5.13.2
    • /release to release-5.13
  3. Automated backport - The bot will automatically create backport PRs to the specified release branches

@probelabs

probelabs Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

This PR provides a comprehensive fix for a set of critical race conditions and state leakage issues within the GraphQL engine. The core problem was that request-specific data, such as HTTP round trippers and resolved headers, was being written to shared, long-lived state. Under concurrent load, this led to non-deterministic behavior where one request could inadvertently use data from another.

The solution refactors the engine to pass all request-specific transport configuration via the request's context.Context, ensuring that each request operates in complete isolation. This eliminates the shared mutable state that caused the race conditions and significantly improves the stability, security, and predictability of GraphQL APIs.

Key changes include:

  • Request-Scoped Transports: The RoundTripper and header configurations are now carried in the request context, preventing concurrent requests from overwriting each other's settings.
  • Subscription Lifecycle Fix: WebSocket subscriptions are now tied to the API definition's lifecycle, preventing them from being prematurely terminated when the initial upgrade request completes.
  • Corrected Header Handling: A bug that caused consumer authentication headers to be forwarded to the upstream twice has been resolved. Additionally, multi-value headers (e.g., Accept-Encoding) are now preserved correctly.
  • Dynamic Header Resolution: Variables in upstream headers (e.g., $tyk_context.*) are now resolved before subscription connection keys are generated. This fixes a critical bug that could cause different users' subscriptions to be incorrectly pooled onto a single upstream connection.
  • Extensive Test Coverage: A comprehensive new test suite has been added to validate request and credential isolation across various modes (Proxy-only, UDG, Supergraph) and protocols (HTTP, SSE, WebSocket), providing strong guarantees against future regressions.

Files Changed Analysis

The changes are spread across 22 files, with a significant net addition of nearly 3000 lines, the vast majority of which are new tests. The most notable additions are internal/graphengine/credential_isolation_test.go (+1229 lines) and internal/graphengine/transport_test.go (+554 lines), which provide exhaustive coverage for the fixes.

Core logic changes are concentrated in internal/graphengine/:

  • context.go: Introduces the mechanism for carrying transport configuration within the request context.
  • transport.go: The main RoundTrip method was refactored to dynamically construct a per-request transport from the context, rather than using a shared, mutated one.
  • graphql_go_tools_v*.go: The PreHandle functions were updated to populate the request context instead of modifying the shared httpClient.Transport.
  • gateway/reverse_proxy.go: The variableReplaceRoundTripper workaround was removed, as variable resolution is now handled correctly within the engine's header modifier.

Architecture & Impact Assessment

  • What this PR accomplishes: It resolves a fundamental architectural flaw in the GraphQL engine's handling of concurrent requests, significantly improving the stability, security, and predictability of GraphQL APIs.
  • Key technical changes introduced:
    1. Shift from Shared State to Request Context: The primary architectural change is moving transport configuration from a mutable field on a shared http.Client to an immutable value within each request's context.Context.
    2. Decoupled Subscription Context: The lifecycle of a subscription is now managed by a context derived from the API specification, not the initial, short-lived HTTP request. This ensures subscriptions remain active until the API is reloaded.
    3. Refined Header Processing Logic: The responsibility for variable replacement and header forwarding has been clarified and corrected, removing workarounds and fixing bugs related to duplicate or collapsed headers.
  • Affected system components: The entire GraphQL subsystem (internal/graphengine) is impacted. All GraphQL APIs, regardless of their configuration (proxy-only, UDG, supergraph) or protocol, will benefit from these fixes. There are notable behavioral changes, such as subscriptions now terminating on API reload, which is the correct and expected behavior.

Architectural Flow: Before vs. After

The following diagrams illustrate the shift from a mutable, shared transport (prone to race conditions) to an isolated, context-scoped approach.

Old Architecture (Race Condition)

sequenceDiagram
    participant ClientA
    participant ClientB
    participant GQL Engine
    participant SharedClient

    ClientA->>GQL Engine: Request A (with TransportA)
    GQL Engine->>SharedClient: Transport = TransportA
    ClientB->>GQL Engine: Request B (with TransportB)
    GQL Engine->>SharedClient: Transport = TransportB (overwrites A)
    GQL Engine->>SharedClient: RoundTrip(reqA)
    Note right of SharedClient: Uses TransportB for Request A! (RACE)
Loading

New Architecture (Isolated)

sequenceDiagram
    participant ClientA
    participant ClientB
    participant GQL Engine
    participant Transport
    participant reqA Context
    participant reqB Context

    ClientA->>GQL Engine: Request A (with RoundTripperA)
    GQL Engine->>reqA Context: SetValue(RoundTripperA)

    ClientB->>GQL Engine: Request B (with RoundTripperB)
    GQL Engine->>reqB Context: SetValue(RoundTripperB)

    GQL Engine->>Transport: RoundTrip(reqA)
    Transport->>reqA Context: GetValue() returns RoundTripperA
    Note right of Transport: Uses correct RoundTripper for each request.
Loading

Scope Discovery & Context Expansion

  • The impact of this change is broad, affecting the security, correctness, and stability of all GraphQL APIs. The fix for dynamic header resolution is particularly critical, as it prevents a security vulnerability where users could have their credentials leaked or their subscription data mixed with others.
  • The change to the subscription lifecycle is a significant behavioral shift. Previously, subscriptions could outlive an API reload; now, they are correctly terminated, which improves resource management and system consistency.
  • The removal of the variableReplaceRoundTripper simplifies the request path and eliminates a source of subtle bugs, such as the incorrect collapsing of multi-value headers (e.g., Accept-Encoding).
Metadata
  • Review Effort: 5 / 5
  • Primary Label: bug

Powered by Visor from Probelabs

Last updated: 2026-08-21T09:08:24.271Z | Triggered by: pr_updated | Commit: 1060276

💡 TIP: You can chat with Visor using /visor ask <your question>

@probelabs

probelabs Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ Security Check Passed

No security issues found – changes LGTM.

✅ Architecture Check Passed

No architecture issues found – changes LGTM.

Performance Issues (2)

Severity Location Issue
🟡 Warning internal/graphengine/graphql_go_tools_v1.go:94-101
The header modifier allocates a new slice for every header processed, even if the `variableReplacer` does not change any of the header's values. In a hot path with many upstream fetches, this can lead to unnecessary memory allocations.
💡 SuggestionConsider using a lazy allocation pattern. Only create a new slice for the header values if at least one value is actually changed by the `variableReplacer`. This avoids allocations when no variables are present in a header's values.
🟡 Warning internal/graphengine/graphql_go_tools_v2.go:88-95
The header modifier allocates a new slice for every header processed, even if the `variableReplacer` does not change any of the header's values. In a hot path with many upstream fetches, this can lead to unnecessary memory allocations.
💡 SuggestionConsider using a lazy allocation pattern. Only create a new slice for the header values if at least one value is actually changed by the `variableReplacer`. This avoids allocations when no variables are present in a header's values.

✅ Quality Check Passed

No quality issues found – changes LGTM.


Powered by Visor from Probelabs

Last updated: 2026-08-21T09:08:20.031Z | Triggered by: pr_updated | Commit: 1060276

💡 TIP: You can chat with Visor using /visor ask <your question>

@pvormste pvormste changed the title Tt 17921 [TT-17921] Improve header handling for GraphQL-based APIs Aug 20, 2026
Three defects found while completing the credential isolation matrix.

1. Websocket subscriptions never delivered anything. The handovers passed the
   request context to the subscription handler, but net/http cancels that
   context as soon as ServeHTTP returns, before the hijacked check in
   conn.serve, and the gateway returns as soon as the connection is hijacked.
   The subscription was therefore cancelled right after connection_ack, without
   ever reaching the upstream.

   Subscriptions now run on a context built from the engine spec context with
   the request's transport values copied across. They keep the per-request round
   tripper, survive the handler returning, and end when the API is released.
   That last part also closes a leak: before, a reloaded API's subscriptions ran
   on because the library used context.Background.

   EngineV1 passes no context at all: its legacy data sources build the upstream
   request with http.NewRequest and drop the execution context, so there is
   nothing request scoped to carry over.

2. GraphQLProxyOnlyContextValues.upstreamResponse was written by the transport
   and read by the engine without a lock. For a subscription the fetch runs on
   the resolver's trigger goroutine, so the two raced; reproduced roughly one run
   in twenty under -race. Both sides now go through accessors.

3. handleProxyOnly took the method of the caller's request for every upstream
   call, which turned an upstream websocket handshake into a POST and got it
   rejected. Handshakes now keep their method.

Defects 2 and 3 predate the transport scoping work.
Patches 0001 and 0002 covered execution engine mode only, over HTTP, SSE and
graphql-ws, with sequential callers. The measured defect also covers proxy-only
and supergraph, and it is a shared state defect, so the sequential cases are the
weaker ones.

Adds, in a new credential_isolation_test.go that holds the shared fixtures:

  - proxy-only HTTP, SSE and graphql-ws, for both engines. The HTTP cases assert
    a plain caller header reached the upstream too, which is what proves the
    handleProxyOnly branch ran.
  - supergraph, which is the only mode that turns on the data loader and single
    flight. Both the root fetch and the _entities fetch are asserted, and
    NewEngineV3 is pinned to reject supergraph.
  - graphql-transport-ws, the second upstream protocol handler.
  - the client websocket upgrade path, which nothing covered before and which is
    where the subscription context defect hid.
  - negative controls: an unauthenticated caller after an authenticated one,
    StripAuthData, and a custom auth header name.
  - concurrent callers, in two flavours. Credential isolation alone does not
    prove transport isolation, because callers sharing one round tripper cannot
    tell a shared client transport from a request scoped one, so a second test
    gives every caller its own.

The tests patches 0001 and 0002 added keep their names and assertions and move
onto the shared builders.

Engine v3 has no websocket upgrade test: 3-preview starts the upstream
subscription with the right credential but never writes a payload back to the
client, independent of the subscription context.
… path

Both modules go to 1ff2d4d7, which carries the schema hashing and cached plan
fixes. The v2 module needed its own bump: v2/ is a separate Go module, so the
earlier bump of the root module left it on the old commit and still mutating the
cached plan at resolve time.

With those in, the concurrent isolation tests no longer need a warm-up to stay
clean under -race, so they gain a cold twin. The cold burst is what exercises the
lazily initialised state that used to race: against the previous pin it reports
races in Schema.Hash and in ResolveGraphQLResponse. The warm variants stay, since
a cached plan is the condition the credential defect itself needs.
Applies scratchpad patch 0003 and completes its coverage.

Config version 2 never resolved a dynamic upstream header where the library
needs it resolved. The v1 header modifier merged Tyk's additional headers and
stopped, leaving tykVariableReplacer injected but unread. That matters because
the library applies the modifier before it derives the key that decides whether
two callers share an upstream connection: connectionKey in the v1 subscription
client, UniqueRequestID in the v2 resolver. A value still holding the literal
$tyk_context token is identical for every caller, so callers hash alike and
multiplex onto one upstream WebSocket opened with the first caller's credential.
Twenty concurrent callers produced exactly one upstream connection.

Resolving there is only safe now that graphql-go-tools f75d6891 moved header
modification to the execution phase. TT-14357 had removed it precisely because
the modifier ran at plan post-process time and the result was cached into the
plan, and added variableReplaceRoundTripper as the workaround.

That workaround is retired here. It resolved too late for the connection key,
collapsed multi-value headers to their first value on the way to the wire, and
expanded variables inside header values the caller had sent, which let a caller
read their own session context back out of the upstream request. Its one
remaining job, request_headers_rewrite, is now resolved once where the rules are
built, from configuration only.

Both modifiers also keep every value of a multi-value header and canonicalise
the key they write, which a direct map assignment does not do on its own.

Behaviour change worth noting: no value is resolved twice any more, on config
version 2 or 3-preview. A value that resolved to something containing a second
variable token used to get another pass.
Repairs a regression from cf506a9, found while verifying patch 0003.
TestGraphQL_InternalDataSource/graphql_engine_v1 fails on the branch tip and
passes at cf83ddb: a config version 1 API with a tyk:// data source answers
500 instead of proxying.

Before the branch, reverseProxyPreHandlerV1 assigned params.RoundTripper onto
the shared httpClient.Transport of the API. That was the cross-caller defect
patch 0001 set out to remove, and it was also the only thing giving EngineV1 the
gateway round tripper. Only that round tripper can route tyk://, so removing it
left those fetches on the plain transport of the API client, which cannot.

The per-request replacement cannot reach EngineV1: its legacy data sources build
the upstream request with http.NewRequest and drop the execution context, so
the context values never arrive. The round tripper is therefore handed over as a
transport level fallback, used only when a request carries none of its own.
EngineV2 and EngineV3 are unaffected and stay fully request scoped.

The fallback is the one piece of the engine transport shared between callers, so
it is mutex guarded and refreshed per request rather than stored once, since the
gateway rebuilds an API's transport when max_conn_time expires. Sharing it is
only sound because the round tripper the gateway hands over no longer carries
caller state - the wrapper that made it caller specific went away in the
previous commit. Both halves are commented so neither gets undone alone.

No concurrent EngineV1 test: the version 1 planner in graphql-go-tools is not
safe for concurrent execution, with no Tyk code on the racing stacks, and
pkg/execution and pkg/astvisitor are identical across the pinned versions.
Pre-existing property of the deprecated config version, not this package's to
guard. The thread safety that is ours is covered directly instead.
A proxy-only API with strip_auth_data off sent the credential to the upstream
twice:

    UPSTREAM_SAW x-api-key=eyJ...M2E=,eyJ...M2E=

Two writers put it there and neither knows about the other. The engine adds the
caller's auth header to the fetch input through propagateAuthHeaders, then
setProxyOnlyHeaders forwards every caller header again with Header.Add, and
X-Api-Key is not one of the three keys in ignoreForwardedHeaders. With
use_immutable_headers off nothing deletes the first copy.

Neither writer is new. propagateAuthHeaders arrived in TT-16017 (bc2c9d1),
14 months after TT-14357 (adca34d) added variableReplaceRoundTripper, which
ran after setProxyOnlyHeaders and did Set(key, Get(key)) on every key. That
collapsed the duplicate, so the double-write was masked from the day it was
written. Retiring the wrapper in 4d67f9a exposed it.

setProxyOnlyHeaders now skips a value the engine already placed on the request.
Compared against a snapshot taken before forwarding rather than the header as it
grows, so a caller that really did send the same value twice keeps both, and a
configured request_headers value that differs from the caller's still reaches
the upstream alongside it - TT-11990 and TT-12190 semantics are unchanged.
Behaviour does change for any exact overlap between a configured value and a
caller value, not only for the auth header: it now goes upstream once.

Also hoists the use_immutable_headers delete out of the value loop. Inside it,
it removed the value added on the previous pass, so a caller header with more
than one value reached the upstream holding only its last one.

The tests could not see any of this. authorizationRecorder reported header
values through Header.Get and TestGraphQL_ProxyOnlyHeaders through val[0], so
all six proxy-only isolation tests passed while duplicating. They now assert the
full value list: with the fix reverted, eleven tests fail and name the duplicate.
@pvormste pvormste added the deps-reviewed Dependency changes reviewed and approved for CI execution label Aug 20, 2026
@pvormste
pvormste enabled auto-merge (squash) August 20, 2026 13:08
@sentinelone-cnapp-eu1

Copy link
Copy Markdown

SentinelOne CNS Hardcoded Secret Detector
✅ Congratulations, your code is safe

SentinelOne CNS is a cloud-agnostic, agentless CSPM & CWPP solution that continuously detects and prevents vulnerabilities that have the highest probability of being exploited in Azure, AWS, Google Cloud, and Kubernetes.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Passed Quality Gate passed

Issues
0 New issues
5 Accepted issues

Measures
0 Security Hotspots
95.1% Coverage on New Code
1.5% Duplication on New Code

See analysis details on SonarQube Cloud

@pvormste
pvormste merged commit d55ab6b into master Aug 21, 2026
134 of 141 checks passed
@pvormste
pvormste deleted the TT-17921 branch August 21, 2026 11:55
@pvormste

Copy link
Copy Markdown
Collaborator Author

/release to release-5.13

@probelabs

probelabs Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

⚠️ Cherry-pick encountered conflicts. A draft PR was created: #8639

pvormste added a commit that referenced this pull request Aug 21, 2026
Values that belong to a single request — the round tripper, the resolved
upstream headers, the
upstream response — were being written into, or keyed on, state that
outlives the request: the
shared per-API `http.Client`, the plan cache, and the upstream
connection-reuse key. Under
concurrency the result was not deterministic: which request's values a
fetch used depended on
timing.

This makes that state request-scoped, and fixes the header handling that
depended on it. Seven
commits, each independently revertable.

`internal/graphengine` goes from 22 to 76 test functions (273 including
subtests), green under
`-race -count=10`.

Behavioural detail and reproduction steps are on the ticket.

`reverseProxyPreHandlerV1/V2.PreHandle` assigned `params.RoundTripper`
onto the per-API
`httpClient.Transport` on every request, so concurrent requests
overwrote each other's. The round
tripper and headers config now travel in the request context
(`SetGraphQLEngineTransportContextValue`), and
`GraphQLEngineTransport.RoundTrip` builds a
per-request view from them, falling back to the client's own transport.

Three defects, two of them introduced by the commit above:

- **WebSocket subscriptions never delivered.** The handovers passed
`WithContext(params.OutRequest.Context())`, but `net/http` calls
`w.cancelCtx()` as soon as
`ServeHTTP` returns — before the hijacked check in `conn.serve` — and
the gateway returns as soon
as the connection is hijacked. The subscription context was cancelled
before anything was sent.
`subscriptionRequestContext` roots the subscription at the long-lived
engine spec context while
carrying the opening request's transport values across. EngineV1 passes
no context: its legacy
data sources drop it, so inheriting the request's cancellation could
only break the subscription.
- **`handleProxyOnly` rewrote the outgoing method**, turning a
proxy-only upstream WebSocket
handshake into a POST, which WebSocket servers reject. Handshakes now
stay `GET`.
- **`GraphQLProxyOnlyContextValues.upstreamResponse` was
unsynchronised.** The transport writes it
on the resolver's trigger goroutine while the engine reads it (~1 in 20
under `-race`). Now
  behind a mutex.

`credential_isolation_test.go`: proxy-only, UDG and supergraph × HTTP,
SSE, `graphql-ws` and
`graphql-transport-ws` × EngineV2 and EngineV3. Each test drives more
than one request through one
engine and asserts what each upstream connection actually received, with
negative controls
(`strip_auth_data`, a custom auth header name, a request that sets no
header after one that did),
20-request concurrent bursts, a per-request round tripper test that
shared mutable transport state
cannot pass, and the client WebSocket-upgrade path that the regression
above had slipped through.

Both module pins move to the library branch; `coder/websocket` becomes a
direct dependency. Two
races are fixed upstream — v1 `Schema.Hash` writing `s.hash` on first
use, and v2
`ResolveGraphQLResponse` writing `Info` on the cached plan — so the
cold-start concurrent burst
becomes permanent coverage instead of something a warm-up had to work
around.

The v1 header modifier merged Tyk's additional headers and stopped,
leaving `tykVariableReplacer`
injected but never read, so `$tyk_context.*` in an upstream header
reached the fetch unresolved on
config version 2. That matters for more than the value that arrives
upstream: the library applies
the modifier before it derives the key that groups equivalent
subscriptions onto one upstream
connection (`connectionKey` in the v1 subscription client,
`UniqueRequestID` in the v2 resolver).
An unresolved template is the same string for every request, so requests
that should have been
distinct were treated as equivalent and pooled together.

Resolving there is only correct now that the library moved header
modification to the execution
phase. TT-14357 (`adca34d46`) had removed it because the modifier ran at
plan post-process time and
the result was cached into the plan, and added
`variableReplaceRoundTripper` as the workaround.
That wrapper is retired here: it ran after the grouping key was derived,
it collapsed multi-value
headers via `Get`/`Set` on the way to the wire, and it expanded
variables in header values that did
not come from the API definition. Its one remaining job,
`request_headers_rewrite`, is resolved
once where the rules are built, from configuration only.

Regression from `cf506a971`, caught while verifying the above:
`TestGraphQL_InternalDataSource/graphql_engine_v1` fails at the branch
tip and passes at the base —
a config version 1 API with a `tyk://` data source answers 500. The
transport assignment that
commit removed was also the only thing giving EngineV1 the gateway round
tripper, and only that
round tripper can route `tyk://`. Since EngineV1's data sources drop the
execution context, the
round tripper is handed over as a mutex-guarded transport-level
fallback, used only when a request
carries none of its own. EngineV2 and EngineV3 stay fully
request-scoped.

That fallback is the one piece of transport state shared between
requests, and it is only sound
because the round tripper the gateway hands over no longer carries
request-specific state — the
wrapper retired in `4d67f9af4` did. **The two commits must not be
reverted independently**; both
sides carry comments saying so.

Two writers put the consumer's headers on the upstream request and
neither knew about the other:
`propagateAuthHeaders` adds them to the fetch input when
`strip_auth_data` is off, and
`setProxyOnlyHeaders` then forwards every consumer header again with
`Header.Add`. With
`use_immutable_headers` off nothing removed the first copy, so a header
present in both arrived
duplicated. It went unnoticed because the wrapper retired in `4d67f9af4`
had been collapsing
repeated values with `Get`/`Set` since before the second writer was
added.

`setProxyOnlyHeaders` now skips a value the engine already placed there,
compared against a
snapshot taken before forwarding so a consumer that genuinely sent the
same value twice keeps both.
It also hoists the `use_immutable_headers` delete out of the value loop,
where it removed the value
added on the previous pass and left a multi-value consumer header
holding only its last value.

1. **An API reload or release now ends that API's subscriptions.**
Previously they ran on
   `context.Background()` and outlived the reload.
2. **No header value is resolved twice** on config version 2 or
3-preview. Values that do not come
from the API definition are no longer expanded, and a value that
resolved into another template
   token no longer gets a second pass.
3. **An exact overlap between a configured `request_headers` value and a
consumer value now reaches
the upstream once**, not twice. Differing values still both go upstream
— TT-11990 / TT-12190
   semantics are unchanged.
4. **Multi-value headers survive to the upstream.** `Accept-Encoding:
gzip, deflate, br` used to
   arrive as `gzip` alone.

<!---TykTechnologies/jira-linter starts here-->

<details>
<summary>
<a href="https://tyktech.atlassian.net/browse/TT-17921" title="TT-17921"
target="_blank">TT-17921</a>
</summary>

|         |    |
|---------|----|
| Status  | Merge |
| Summary | Federated supergraph reuses first caller’s JWT across users
and can share HTTP responses via single-flight |

Generated at: 2026-08-20 13:03:45

</details>

<!---TykTechnologies/jira-linter ends here-->
@pvormste

Copy link
Copy Markdown
Collaborator Author

/release to release-5.13.2

@probelabs

probelabs Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

⚠️ Cherry-pick encountered conflicts. A draft PR was created: #8641

pvormste added a commit that referenced this pull request Aug 21, 2026
Values that belong to a single request — the round tripper, the resolved
upstream headers, the
upstream response — were being written into, or keyed on, state that
outlives the request: the
shared per-API `http.Client`, the plan cache, and the upstream
connection-reuse key. Under
concurrency the result was not deterministic: which request's values a
fetch used depended on
timing.

This makes that state request-scoped, and fixes the header handling that
depended on it. Seven
commits, each independently revertable.

`internal/graphengine` goes from 22 to 76 test functions (273 including
subtests), green under
`-race -count=10`.

Behavioural detail and reproduction steps are on the ticket.

`reverseProxyPreHandlerV1/V2.PreHandle` assigned `params.RoundTripper`
onto the per-API
`httpClient.Transport` on every request, so concurrent requests
overwrote each other's. The round
tripper and headers config now travel in the request context
(`SetGraphQLEngineTransportContextValue`), and
`GraphQLEngineTransport.RoundTrip` builds a
per-request view from them, falling back to the client's own transport.

Three defects, two of them introduced by the commit above:

- **WebSocket subscriptions never delivered.** The handovers passed
`WithContext(params.OutRequest.Context())`, but `net/http` calls
`w.cancelCtx()` as soon as
`ServeHTTP` returns — before the hijacked check in `conn.serve` — and
the gateway returns as soon
as the connection is hijacked. The subscription context was cancelled
before anything was sent.
`subscriptionRequestContext` roots the subscription at the long-lived
engine spec context while
carrying the opening request's transport values across. EngineV1 passes
no context: its legacy
data sources drop it, so inheriting the request's cancellation could
only break the subscription.
- **`handleProxyOnly` rewrote the outgoing method**, turning a
proxy-only upstream WebSocket
handshake into a POST, which WebSocket servers reject. Handshakes now
stay `GET`.
- **`GraphQLProxyOnlyContextValues.upstreamResponse` was
unsynchronised.** The transport writes it
on the resolver's trigger goroutine while the engine reads it (~1 in 20
under `-race`). Now
  behind a mutex.

`credential_isolation_test.go`: proxy-only, UDG and supergraph × HTTP,
SSE, `graphql-ws` and
`graphql-transport-ws` × EngineV2 and EngineV3. Each test drives more
than one request through one
engine and asserts what each upstream connection actually received, with
negative controls
(`strip_auth_data`, a custom auth header name, a request that sets no
header after one that did),
20-request concurrent bursts, a per-request round tripper test that
shared mutable transport state
cannot pass, and the client WebSocket-upgrade path that the regression
above had slipped through.

Both module pins move to the library branch; `coder/websocket` becomes a
direct dependency. Two
races are fixed upstream — v1 `Schema.Hash` writing `s.hash` on first
use, and v2
`ResolveGraphQLResponse` writing `Info` on the cached plan — so the
cold-start concurrent burst
becomes permanent coverage instead of something a warm-up had to work
around.

The v1 header modifier merged Tyk's additional headers and stopped,
leaving `tykVariableReplacer`
injected but never read, so `$tyk_context.*` in an upstream header
reached the fetch unresolved on
config version 2. That matters for more than the value that arrives
upstream: the library applies
the modifier before it derives the key that groups equivalent
subscriptions onto one upstream
connection (`connectionKey` in the v1 subscription client,
`UniqueRequestID` in the v2 resolver).
An unresolved template is the same string for every request, so requests
that should have been
distinct were treated as equivalent and pooled together.

Resolving there is only correct now that the library moved header
modification to the execution
phase. TT-14357 (`adca34d46`) had removed it because the modifier ran at
plan post-process time and
the result was cached into the plan, and added
`variableReplaceRoundTripper` as the workaround.
That wrapper is retired here: it ran after the grouping key was derived,
it collapsed multi-value
headers via `Get`/`Set` on the way to the wire, and it expanded
variables in header values that did
not come from the API definition. Its one remaining job,
`request_headers_rewrite`, is resolved
once where the rules are built, from configuration only.

Regression from `cf506a971`, caught while verifying the above:
`TestGraphQL_InternalDataSource/graphql_engine_v1` fails at the branch
tip and passes at the base —
a config version 1 API with a `tyk://` data source answers 500. The
transport assignment that
commit removed was also the only thing giving EngineV1 the gateway round
tripper, and only that
round tripper can route `tyk://`. Since EngineV1's data sources drop the
execution context, the
round tripper is handed over as a mutex-guarded transport-level
fallback, used only when a request
carries none of its own. EngineV2 and EngineV3 stay fully
request-scoped.

That fallback is the one piece of transport state shared between
requests, and it is only sound
because the round tripper the gateway hands over no longer carries
request-specific state — the
wrapper retired in `4d67f9af4` did. **The two commits must not be
reverted independently**; both
sides carry comments saying so.

Two writers put the consumer's headers on the upstream request and
neither knew about the other:
`propagateAuthHeaders` adds them to the fetch input when
`strip_auth_data` is off, and
`setProxyOnlyHeaders` then forwards every consumer header again with
`Header.Add`. With
`use_immutable_headers` off nothing removed the first copy, so a header
present in both arrived
duplicated. It went unnoticed because the wrapper retired in `4d67f9af4`
had been collapsing
repeated values with `Get`/`Set` since before the second writer was
added.

`setProxyOnlyHeaders` now skips a value the engine already placed there,
compared against a
snapshot taken before forwarding so a consumer that genuinely sent the
same value twice keeps both.
It also hoists the `use_immutable_headers` delete out of the value loop,
where it removed the value
added on the previous pass and left a multi-value consumer header
holding only its last value.

1. **An API reload or release now ends that API's subscriptions.**
Previously they ran on
   `context.Background()` and outlived the reload.
2. **No header value is resolved twice** on config version 2 or
3-preview. Values that do not come
from the API definition are no longer expanded, and a value that
resolved into another template
   token no longer gets a second pass.
3. **An exact overlap between a configured `request_headers` value and a
consumer value now reaches
the upstream once**, not twice. Differing values still both go upstream
— TT-11990 / TT-12190
   semantics are unchanged.
4. **Multi-value headers survive to the upstream.** `Accept-Encoding:
gzip, deflate, br` used to
   arrive as `gzip` alone.

<!---TykTechnologies/jira-linter starts here-->

<details>
<summary>
<a href="https://tyktech.atlassian.net/browse/TT-17921" title="TT-17921"
target="_blank">TT-17921</a>
</summary>

|         |    |
|---------|----|
| Status  | Merge |
| Summary | Federated supergraph reuses first caller’s JWT across users
and can share HTTP responses via single-flight |

Generated at: 2026-08-20 13:03:45

</details>

<!---TykTechnologies/jira-linter ends here-->
pvormste added a commit that referenced this pull request Aug 21, 2026
…phQL-based APIs (#8602) (#8641)

Cherry-pick of `d55ab6ba241a233576c7d8fcf469f39f328809fa` from `master`
to `release-5.13.2` requires manual resolution.

  **Conflicts detected:** 3
   - go.mod
  
  Tips:
- Check out this branch locally and run: `git cherry-pick -x
d55ab6b`
- Resolve conflicts (including submodules if any), then push back to
this branch.
  
Original commit:
d55ab6b

---------

Co-authored-by: Tyk Bot <bot@tyk.io>
Co-authored-by: Patric Vormstein <pvormstein@googlemail.com>
pvormste added a commit that referenced this pull request Aug 21, 2026
…QL-based APIs (#8602) (#8639)

Cherry-pick of `d55ab6ba241a233576c7d8fcf469f39f328809fa` from `master`
to `release-5.13` requires manual resolution.

  **Conflicts detected:** 3
   - go.mod
  
  Tips:
- Check out this branch locally and run: `git cherry-pick -x
d55ab6b`
- Resolve conflicts (including submodules if any), then push back to
this branch.
  
Original commit:
d55ab6b

---------

Co-authored-by: Tyk Bot <bot@tyk.io>
Co-authored-by: Patric Vormstein <pvormstein@googlemail.com>
Co-authored-by: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com>
@pvormste

Copy link
Copy Markdown
Collaborator Author

/release to release-5.15.0

@probelabs

probelabs Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

✅ Cherry-pick successful. A PR was created: #8643

@pvormste

Copy link
Copy Markdown
Collaborator Author

/release to release-5.15

@probelabs

probelabs Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

✅ Cherry-pick successful. A PR was created: #8644

pvormste added a commit that referenced this pull request Aug 24, 2026
…phQL-based APIs (#8602) (#8643)

[TT-17921] Improve header handling for GraphQL-based APIs (#8602)

## Summary

Values that belong to a single request — the round tripper, the resolved
upstream headers, the
upstream response — were being written into, or keyed on, state that
outlives the request: the
shared per-API `http.Client`, the plan cache, and the upstream
connection-reuse key. Under
concurrency the result was not deterministic: which request's values a
fetch used depended on
timing.

This makes that state request-scoped, and fixes the header handling that
depended on it. Seven
commits, each independently revertable. 

`internal/graphengine` goes from 22 to 76 test functions (273 including
subtests), green under
`-race -count=10`.

Behavioural detail and reproduction steps are on the ticket.

## What changed

### scope transports to requests

`reverseProxyPreHandlerV1/V2.PreHandle` assigned `params.RoundTripper`
onto the per-API
`httpClient.Transport` on every request, so concurrent requests
overwrote each other's. The round
tripper and headers config now travel in the request context
(`SetGraphQLEngineTransportContextValue`), and
`GraphQLEngineTransport.RoundTrip` builds a
per-request view from them, falling back to the client's own transport.

### keep subscriptions alive, isolate proxy-only responses

Three defects, two of them introduced by the commit above:

- **WebSocket subscriptions never delivered.** The handovers passed
`WithContext(params.OutRequest.Context())`, but `net/http` calls
`w.cancelCtx()` as soon as
`ServeHTTP` returns — before the hijacked check in `conn.serve` — and
the gateway returns as soon
as the connection is hijacked. The subscription context was cancelled
before anything was sent.
`subscriptionRequestContext` roots the subscription at the long-lived
engine spec context while
carrying the opening request's transport values across. EngineV1 passes
no context: its legacy
data sources drop it, so inheriting the request's cancellation could
only break the subscription.
- **`handleProxyOnly` rewrote the outgoing method**, turning a
proxy-only upstream WebSocket
handshake into a POST, which WebSocket servers reject. Handshakes now
stay `GET`.
- **`GraphQLProxyOnlyContextValues.upstreamResponse` was
unsynchronised.** The transport writes it
on the resolver's trigger goroutine while the engine reads it (~1 in 20
under `-race`). Now
  behind a mutex.

### cover the request isolation matrix

`credential_isolation_test.go`: proxy-only, UDG and supergraph × HTTP,
SSE, `graphql-ws` and
`graphql-transport-ws` × EngineV2 and EngineV3. Each test drives more
than one request through one
engine and asserts what each upstream connection actually received, with
negative controls
(`strip_auth_data`, a custom auth header name, a request that sets no
header after one that did),
20-request concurrent bursts, a per-request round tripper test that
shared mutable transport state
cannot pass, and the client WebSocket-upgrade path that the regression
above had slipped through.

### move to the fixed graphql-go-tools, cover the cold path

Both module pins move to the library branch; `coder/websocket` becomes a
direct dependency. Two
races are fixed upstream — v1 `Schema.Hash` writing `s.hash` on first
use, and v2
`ResolveGraphQLResponse` writing `Info` on the cached plan — so the
cold-start concurrent burst
becomes permanent coverage instead of something a warm-up had to work
around.

### resolve dynamic upstream headers before the fetch is keyed

The v1 header modifier merged Tyk's additional headers and stopped,
leaving `tykVariableReplacer`
injected but never read, so `$tyk_context.*` in an upstream header
reached the fetch unresolved on
config version 2. That matters for more than the value that arrives
upstream: the library applies
the modifier before it derives the key that groups equivalent
subscriptions onto one upstream
connection (`connectionKey` in the v1 subscription client,
`UniqueRequestID` in the v2 resolver).
An unresolved template is the same string for every request, so requests
that should have been
distinct were treated as equivalent and pooled together.

Resolving there is only correct now that the library moved header
modification to the execution
phase. TT-14357 (`adca34d46`) had removed it because the modifier ran at
plan post-process time and
the result was cached into the plan, and added
`variableReplaceRoundTripper` as the workaround.
That wrapper is retired here: it ran after the grouping key was derived,
it collapsed multi-value
headers via `Get`/`Set` on the way to the wire, and it expanded
variables in header values that did
not come from the API definition. Its one remaining job,
`request_headers_rewrite`, is resolved
once where the rules are built, from configuration only.

### restore the gateway round tripper for EngineV1 fetches

Regression from `cf506a971`, caught while verifying the above:
`TestGraphQL_InternalDataSource/graphql_engine_v1` fails at the branch
tip and passes at the base —
a config version 1 API with a `tyk://` data source answers 500. The
transport assignment that
commit removed was also the only thing giving EngineV1 the gateway round
tripper, and only that
round tripper can route `tyk://`. Since EngineV1's data sources drop the
execution context, the
round tripper is handed over as a mutex-guarded transport-level
fallback, used only when a request
carries none of its own. EngineV2 and EngineV3 stay fully
request-scoped.

That fallback is the one piece of transport state shared between
requests, and it is only sound
because the round tripper the gateway hands over no longer carries
request-specific state — the
wrapper retired in `4d67f9af4` did. **The two commits must not be
reverted independently**; both
sides carry comments saying so.

### forward a consumer header to the upstream once

Two writers put the consumer's headers on the upstream request and
neither knew about the other:
`propagateAuthHeaders` adds them to the fetch input when
`strip_auth_data` is off, and
`setProxyOnlyHeaders` then forwards every consumer header again with
`Header.Add`. With
`use_immutable_headers` off nothing removed the first copy, so a header
present in both arrived
duplicated. It went unnoticed because the wrapper retired in `4d67f9af4`
had been collapsing
repeated values with `Get`/`Set` since before the second writer was
added.

`setProxyOnlyHeaders` now skips a value the engine already placed there,
compared against a
snapshot taken before forwarding so a consumer that genuinely sent the
same value twice keeps both.
It also hoists the `use_immutable_headers` delete out of the value loop,
where it removed the value
added on the previous pass and left a multi-value consumer header
holding only its last value.

## Behaviour changes worth a reviewer's sign-off

1. **An API reload or release now ends that API's subscriptions.**
Previously they ran on
   `context.Background()` and outlived the reload.
2. **No header value is resolved twice** on config version 2 or
3-preview. Values that do not come
from the API definition are no longer expanded, and a value that
resolved into another template
   token no longer gets a second pass.
3. **An exact overlap between a configured `request_headers` value and a
consumer value now reaches
the upstream once**, not twice. Differing values still both go upstream
— TT-11990 / TT-12190
   semantics are unchanged.
4. **Multi-value headers survive to the upstream.** `Accept-Encoding:
gzip, deflate, br` used to
   arrive as `gzip` alone.

<!---TykTechnologies/jira-linter starts here-->

### Ticket Details

<details>
<summary>
<a href="https://tyktech.atlassian.net/browse/TT-17921" title="TT-17921"
target="_blank">TT-17921</a>
</summary>

|         |    |
|---------|----|
| Status  | Merge |
| Summary | Federated supergraph reuses first caller’s JWT across users
and can share HTTP responses via single-flight |

Generated at: 2026-08-20 13:03:45

</details>

<!---TykTechnologies/jira-linter ends here-->

[TT-17921]:
https://tyktech.atlassian.net/browse/TT-17921?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

Co-authored-by: Patric Vormstein <pvormstein@googlemail.com>
pvormste added a commit that referenced this pull request Aug 24, 2026
…QL-based APIs (#8602) (#8644)

[TT-17921] Improve header handling for GraphQL-based APIs (#8602)

## Summary

Values that belong to a single request — the round tripper, the resolved
upstream headers, the
upstream response — were being written into, or keyed on, state that
outlives the request: the
shared per-API `http.Client`, the plan cache, and the upstream
connection-reuse key. Under
concurrency the result was not deterministic: which request's values a
fetch used depended on
timing.

This makes that state request-scoped, and fixes the header handling that
depended on it. Seven
commits, each independently revertable. 

`internal/graphengine` goes from 22 to 76 test functions (273 including
subtests), green under
`-race -count=10`.

Behavioural detail and reproduction steps are on the ticket.

## What changed

### scope transports to requests

`reverseProxyPreHandlerV1/V2.PreHandle` assigned `params.RoundTripper`
onto the per-API
`httpClient.Transport` on every request, so concurrent requests
overwrote each other's. The round
tripper and headers config now travel in the request context
(`SetGraphQLEngineTransportContextValue`), and
`GraphQLEngineTransport.RoundTrip` builds a
per-request view from them, falling back to the client's own transport.

### keep subscriptions alive, isolate proxy-only responses

Three defects, two of them introduced by the commit above:

- **WebSocket subscriptions never delivered.** The handovers passed
`WithContext(params.OutRequest.Context())`, but `net/http` calls
`w.cancelCtx()` as soon as
`ServeHTTP` returns — before the hijacked check in `conn.serve` — and
the gateway returns as soon
as the connection is hijacked. The subscription context was cancelled
before anything was sent.
`subscriptionRequestContext` roots the subscription at the long-lived
engine spec context while
carrying the opening request's transport values across. EngineV1 passes
no context: its legacy
data sources drop it, so inheriting the request's cancellation could
only break the subscription.
- **`handleProxyOnly` rewrote the outgoing method**, turning a
proxy-only upstream WebSocket
handshake into a POST, which WebSocket servers reject. Handshakes now
stay `GET`.
- **`GraphQLProxyOnlyContextValues.upstreamResponse` was
unsynchronised.** The transport writes it
on the resolver's trigger goroutine while the engine reads it (~1 in 20
under `-race`). Now
  behind a mutex.

### cover the request isolation matrix

`credential_isolation_test.go`: proxy-only, UDG and supergraph × HTTP,
SSE, `graphql-ws` and
`graphql-transport-ws` × EngineV2 and EngineV3. Each test drives more
than one request through one
engine and asserts what each upstream connection actually received, with
negative controls
(`strip_auth_data`, a custom auth header name, a request that sets no
header after one that did),
20-request concurrent bursts, a per-request round tripper test that
shared mutable transport state
cannot pass, and the client WebSocket-upgrade path that the regression
above had slipped through.

### move to the fixed graphql-go-tools, cover the cold path

Both module pins move to the library branch; `coder/websocket` becomes a
direct dependency. Two
races are fixed upstream — v1 `Schema.Hash` writing `s.hash` on first
use, and v2
`ResolveGraphQLResponse` writing `Info` on the cached plan — so the
cold-start concurrent burst
becomes permanent coverage instead of something a warm-up had to work
around.

### resolve dynamic upstream headers before the fetch is keyed

The v1 header modifier merged Tyk's additional headers and stopped,
leaving `tykVariableReplacer`
injected but never read, so `$tyk_context.*` in an upstream header
reached the fetch unresolved on
config version 2. That matters for more than the value that arrives
upstream: the library applies
the modifier before it derives the key that groups equivalent
subscriptions onto one upstream
connection (`connectionKey` in the v1 subscription client,
`UniqueRequestID` in the v2 resolver).
An unresolved template is the same string for every request, so requests
that should have been
distinct were treated as equivalent and pooled together.

Resolving there is only correct now that the library moved header
modification to the execution
phase. TT-14357 (`adca34d46`) had removed it because the modifier ran at
plan post-process time and
the result was cached into the plan, and added
`variableReplaceRoundTripper` as the workaround.
That wrapper is retired here: it ran after the grouping key was derived,
it collapsed multi-value
headers via `Get`/`Set` on the way to the wire, and it expanded
variables in header values that did
not come from the API definition. Its one remaining job,
`request_headers_rewrite`, is resolved
once where the rules are built, from configuration only.

### restore the gateway round tripper for EngineV1 fetches

Regression from `cf506a971`, caught while verifying the above:
`TestGraphQL_InternalDataSource/graphql_engine_v1` fails at the branch
tip and passes at the base —
a config version 1 API with a `tyk://` data source answers 500. The
transport assignment that
commit removed was also the only thing giving EngineV1 the gateway round
tripper, and only that
round tripper can route `tyk://`. Since EngineV1's data sources drop the
execution context, the
round tripper is handed over as a mutex-guarded transport-level
fallback, used only when a request
carries none of its own. EngineV2 and EngineV3 stay fully
request-scoped.

That fallback is the one piece of transport state shared between
requests, and it is only sound
because the round tripper the gateway hands over no longer carries
request-specific state — the
wrapper retired in `4d67f9af4` did. **The two commits must not be
reverted independently**; both
sides carry comments saying so.

### forward a consumer header to the upstream once

Two writers put the consumer's headers on the upstream request and
neither knew about the other:
`propagateAuthHeaders` adds them to the fetch input when
`strip_auth_data` is off, and
`setProxyOnlyHeaders` then forwards every consumer header again with
`Header.Add`. With
`use_immutable_headers` off nothing removed the first copy, so a header
present in both arrived
duplicated. It went unnoticed because the wrapper retired in `4d67f9af4`
had been collapsing
repeated values with `Get`/`Set` since before the second writer was
added.

`setProxyOnlyHeaders` now skips a value the engine already placed there,
compared against a
snapshot taken before forwarding so a consumer that genuinely sent the
same value twice keeps both.
It also hoists the `use_immutable_headers` delete out of the value loop,
where it removed the value
added on the previous pass and left a multi-value consumer header
holding only its last value.

## Behaviour changes worth a reviewer's sign-off

1. **An API reload or release now ends that API's subscriptions.**
Previously they ran on
   `context.Background()` and outlived the reload.
2. **No header value is resolved twice** on config version 2 or
3-preview. Values that do not come
from the API definition are no longer expanded, and a value that
resolved into another template
   token no longer gets a second pass.
3. **An exact overlap between a configured `request_headers` value and a
consumer value now reaches
the upstream once**, not twice. Differing values still both go upstream
— TT-11990 / TT-12190
   semantics are unchanged.
4. **Multi-value headers survive to the upstream.** `Accept-Encoding:
gzip, deflate, br` used to
   arrive as `gzip` alone.

<!---TykTechnologies/jira-linter starts here-->

### Ticket Details

<details>
<summary>
<a href="https://tyktech.atlassian.net/browse/TT-17921" title="TT-17921"
target="_blank">TT-17921</a>
</summary>

|         |    |
|---------|----|
| Status  | Merge |
| Summary | Federated supergraph reuses first caller’s JWT across users
and can share HTTP responses via single-flight |

Generated at: 2026-08-20 13:03:45

</details>

<!---TykTechnologies/jira-linter ends here-->

[TT-17921]:
https://tyktech.atlassian.net/browse/TT-17921?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

Co-authored-by: Patric Vormstein <pvormstein@googlemail.com>
@ilijabojanovic

Copy link
Copy Markdown
Member

/release to release-5.8

@probelabs

probelabs Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

⚠️ Cherry-pick encountered conflicts. A draft PR was created: #8689

pvormste added a commit that referenced this pull request Sep 11, 2026
Values that belong to a single request — the round tripper, the resolved
upstream headers, the
upstream response — were being written into, or keyed on, state that
outlives the request: the
shared per-API `http.Client`, the plan cache, and the upstream
connection-reuse key. Under
concurrency the result was not deterministic: which request's values a
fetch used depended on
timing.

This makes that state request-scoped, and fixes the header handling that
depended on it. Seven
commits, each independently revertable.

`internal/graphengine` goes from 22 to 76 test functions (273 including
subtests), green under
`-race -count=10`.

Behavioural detail and reproduction steps are on the ticket.

`reverseProxyPreHandlerV1/V2.PreHandle` assigned `params.RoundTripper`
onto the per-API
`httpClient.Transport` on every request, so concurrent requests
overwrote each other's. The round
tripper and headers config now travel in the request context
(`SetGraphQLEngineTransportContextValue`), and
`GraphQLEngineTransport.RoundTrip` builds a
per-request view from them, falling back to the client's own transport.

Three defects, two of them introduced by the commit above:

- **WebSocket subscriptions never delivered.** The handovers passed
`WithContext(params.OutRequest.Context())`, but `net/http` calls
`w.cancelCtx()` as soon as
`ServeHTTP` returns — before the hijacked check in `conn.serve` — and
the gateway returns as soon
as the connection is hijacked. The subscription context was cancelled
before anything was sent.
`subscriptionRequestContext` roots the subscription at the long-lived
engine spec context while
carrying the opening request's transport values across. EngineV1 passes
no context: its legacy
data sources drop it, so inheriting the request's cancellation could
only break the subscription.
- **`handleProxyOnly` rewrote the outgoing method**, turning a
proxy-only upstream WebSocket
handshake into a POST, which WebSocket servers reject. Handshakes now
stay `GET`.
- **`GraphQLProxyOnlyContextValues.upstreamResponse` was
unsynchronised.** The transport writes it
on the resolver's trigger goroutine while the engine reads it (~1 in 20
under `-race`). Now
  behind a mutex.

`credential_isolation_test.go`: proxy-only, UDG and supergraph × HTTP,
SSE, `graphql-ws` and
`graphql-transport-ws` × EngineV2 and EngineV3. Each test drives more
than one request through one
engine and asserts what each upstream connection actually received, with
negative controls
(`strip_auth_data`, a custom auth header name, a request that sets no
header after one that did),
20-request concurrent bursts, a per-request round tripper test that
shared mutable transport state
cannot pass, and the client WebSocket-upgrade path that the regression
above had slipped through.

Both module pins move to the library branch; `coder/websocket` becomes a
direct dependency. Two
races are fixed upstream — v1 `Schema.Hash` writing `s.hash` on first
use, and v2
`ResolveGraphQLResponse` writing `Info` on the cached plan — so the
cold-start concurrent burst
becomes permanent coverage instead of something a warm-up had to work
around.

The v1 header modifier merged Tyk's additional headers and stopped,
leaving `tykVariableReplacer`
injected but never read, so `$tyk_context.*` in an upstream header
reached the fetch unresolved on
config version 2. That matters for more than the value that arrives
upstream: the library applies
the modifier before it derives the key that groups equivalent
subscriptions onto one upstream
connection (`connectionKey` in the v1 subscription client,
`UniqueRequestID` in the v2 resolver).
An unresolved template is the same string for every request, so requests
that should have been
distinct were treated as equivalent and pooled together.

Resolving there is only correct now that the library moved header
modification to the execution
phase. TT-14357 (`adca34d46`) had removed it because the modifier ran at
plan post-process time and
the result was cached into the plan, and added
`variableReplaceRoundTripper` as the workaround.
That wrapper is retired here: it ran after the grouping key was derived,
it collapsed multi-value
headers via `Get`/`Set` on the way to the wire, and it expanded
variables in header values that did
not come from the API definition. Its one remaining job,
`request_headers_rewrite`, is resolved
once where the rules are built, from configuration only.

Regression from `cf506a971`, caught while verifying the above:
`TestGraphQL_InternalDataSource/graphql_engine_v1` fails at the branch
tip and passes at the base —
a config version 1 API with a `tyk://` data source answers 500. The
transport assignment that
commit removed was also the only thing giving EngineV1 the gateway round
tripper, and only that
round tripper can route `tyk://`. Since EngineV1's data sources drop the
execution context, the
round tripper is handed over as a mutex-guarded transport-level
fallback, used only when a request
carries none of its own. EngineV2 and EngineV3 stay fully
request-scoped.

That fallback is the one piece of transport state shared between
requests, and it is only sound
because the round tripper the gateway hands over no longer carries
request-specific state — the
wrapper retired in `4d67f9af4` did. **The two commits must not be
reverted independently**; both
sides carry comments saying so.

Two writers put the consumer's headers on the upstream request and
neither knew about the other:
`propagateAuthHeaders` adds them to the fetch input when
`strip_auth_data` is off, and
`setProxyOnlyHeaders` then forwards every consumer header again with
`Header.Add`. With
`use_immutable_headers` off nothing removed the first copy, so a header
present in both arrived
duplicated. It went unnoticed because the wrapper retired in `4d67f9af4`
had been collapsing
repeated values with `Get`/`Set` since before the second writer was
added.

`setProxyOnlyHeaders` now skips a value the engine already placed there,
compared against a
snapshot taken before forwarding so a consumer that genuinely sent the
same value twice keeps both.
It also hoists the `use_immutable_headers` delete out of the value loop,
where it removed the value
added on the previous pass and left a multi-value consumer header
holding only its last value.

1. **An API reload or release now ends that API's subscriptions.**
Previously they ran on
   `context.Background()` and outlived the reload.
2. **No header value is resolved twice** on config version 2 or
3-preview. Values that do not come
from the API definition are no longer expanded, and a value that
resolved into another template
   token no longer gets a second pass.
3. **An exact overlap between a configured `request_headers` value and a
consumer value now reaches
the upstream once**, not twice. Differing values still both go upstream
— TT-11990 / TT-12190
   semantics are unchanged.
4. **Multi-value headers survive to the upstream.** `Accept-Encoding:
gzip, deflate, br` used to
   arrive as `gzip` alone.

<!---TykTechnologies/jira-linter starts here-->

<details>
<summary>
<a href="https://tyktech.atlassian.net/browse/TT-17921" title="TT-17921"
target="_blank">TT-17921</a>
</summary>

|         |    |
|---------|----|
| Status  | Merge |
| Summary | Federated supergraph reuses first caller’s JWT across users
and can share HTTP responses via single-flight |

Generated at: 2026-08-20 13:03:45

</details>

<!---TykTechnologies/jira-linter ends here-->
ilijabojanovic pushed a commit that referenced this pull request Sep 11, 2026
…L-based APIs (#8602) (#8689)

Cherry-pick of `d55ab6ba241a233576c7d8fcf469f39f328809fa` from `master`
to `release-5.8` requires manual resolution.

  **Conflicts detected:** 6
   - go.mod
 - go.sum
  
  Tips:
- Check out this branch locally and run: `git cherry-pick -x
d55ab6b`
- Resolve conflicts (including submodules if any), then push back to
this branch.
  
Original commit:
d55ab6b

---------

Co-authored-by: Tyk Bot <bot@tyk.io>
Co-authored-by: Patric Vormstein <pvormstein@googlemail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deps-reviewed Dependency changes reviewed and approved for CI execution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants