Skip to content

fix(sdk): give each invocation independently owned header values (#7771) - #7786

Merged
Aias00 merged 1 commit into
apache:masterfrom
konlue:fix/sdk-header-ownership
Oct 19, 2026
Merged

Aias00 merged 1 commit into
apache:masterfrom
konlue:fix/sdk-header-ownership

Conversation

@konlue

@konlue konlue commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #7771.

Summary

RequestTemplate.from() shares header value collections with the template it copies from. Since targetProcessor runs from → request() → parameter processors against a long-lived per-method template, dynamic header/cookie values mutate collections the template still owns: a static Collections.singleton header paired with a dynamic value of the same name throws UnsupportedOperationException before any transport call, and dynamic cookies accumulate across invocations.

Modifications

  • RequestTemplate.from() copies header value collections per entry (new ArrayList<>), giving each invocation independently owned values.
  • RequestHeaderParameterProcessor copies an existing collection before appending (both the single-value and map branches).
  • CookieValueParameterProcessor builds the per-call Cookie entry from the invocation's own header map with a defensive copy, and no longer reads or mutates the shared template; its previous compute path could also double-append template values.

Verifying this change

  • New tests: RequestTemplateTest#testFromCopiesHeaderValues (mutating the copy leaves the original untouched), RequestHeaderParameterProcessorTest#processArgumentOnStaticSingletonHeaderTest (static singleton + dynamic same-name header merges, template value unchanged) and CookieValueParameterProcessorTest#processArgumentDoesNotAccumulateAcrossCalls (two invocations stay isolated, template never grows).

  • Red-green: with the deep copy and the cookie read-point change reverted, the singleton and accumulation cases fail with UnsupportedOperationException; with the fix all pass (core 4/4, spring 6/6 + 6/6).

  • Targeted tests pass:

    ./mvnw -B -ntp -pl shenyu-sdk/shenyu-sdk-core,shenyu-sdk/shenyu-sdk-spring test -Dtest='RequestTemplateTest,RequestHeaderParameterProcessorTest,CookieValueParameterProcessorTest'

    core 4/4, spring 6/6 + 6/6 green.

  • Checkstyle passes:

    ./mvnw -B -ntp -pl shenyu-sdk/shenyu-sdk-core,shenyu-sdk/shenyu-sdk-spring checkstyle:check

    clean.

Notes

Transport-layer consumers (OkHttp/Http clients, load-balance data) only read headers, so per-entry copying has no behavioural downside. request()'s shallow outer-map copy stays: after from(), the value collections are already independently owned. A user ShenyuSdkRequestInterceptor that appends to an existing collection could still hit an immutable value list of its own making — same family of issue, left untouched here since the interceptor map is already per-request at the outer level.

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. I reproduced the root cause independently and the fix lands at the right layer.

Why the defect is real

from() was not the only place that shared collections: the per-method template handed to ShenyuClientMethodHandler is populated by SpringMvcContract, and every static header there is an immutable singleton:

SpringMvcContract:111   requestTemplate.getHeaders().put(ACCEPT, Collections.singleton(clientAccepts));
SpringMvcContract:119   requestTemplate.getHeaders().put(CONTENT_TYPE, Collections.singleton(clientProduces));
SpringMvcContract:123-131 parseHeaders -> Collections.singleton(resolve(...))

ShenyuClientMethodHandler#targetProcessor:87-89 copies that template with from() and then asks request() for the per-call request, and request() (RequestTemplate:89) only copies the outer map — so the value collection stayed shared with the long-lived template. Consequence on master, exactly as described: RequestHeaderParameterProcessor's single-value branch (headerColl.add(...)) and its map branch (old.add(...)) hit UnsupportedOperationException when a static @Headers/produces/consumes header collides with a dynamic one, and @CookieValue accumulated template-owned cookies across invocations.

Why the fix is at the right level

  • from() per-entry copy makes the per-invocation template a real snapshot; request()'s shallow outer-map copy stays a cheap optimisation because the values it hands out are now owned by that snapshot.
  • Copying again before appending in both processors is still needed (the request's values are the snapshot's values, and the snapshot is reused if request() is called twice), so both halves of the change are justified rather than redundant.
  • CookieValueParameterProcessor reading the per-call map instead of the template is a behaviour improvement, not just a refactor: two @CookieValue parameters on one method used to append the second value into a collection that already contained it. A test with two cookie parameters would pin that down.

Non-blocking

  1. new ArrayList<>(value) throws NPE if a template ever holds a null value entry. No writer in the repo does that today, but a Objects.requireNonNull/skip would make the copy total.
  2. The "Notes" section says a user ShenyuSdkRequestInterceptor can still hit an immutable value list. For the standard path that is no longer true: after from() every value in the per-call request is an ArrayList, so request.getHeaders().get("X").add(...) from an interceptor is safe too. Suggest updating the description (or adding a test) so the next reader does not assume the hole is still open.
  3. Worth documenting the ownership contract on request() as well: its value collections belong to the template it is invoked on and must not be mutated by callers. That is the invariant the two processors now rely on.

CI on 78a58a9 is green (pr_build, it, sdk-http/sdk-apache-dubbo, e2e).

@Aias00
Aias00 merged commit 5ebaa34 into apache:master Oct 19, 2026
29 checks passed
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.

[BUG] Spring SDK dynamic headers mutate immutable or template-owned value collections

2 participants