Skip to content

Stop the payload content type from clobbering a modeled Accept header - #220

Merged
aaylward merged 1 commit into
mainfrom
claude/kind-fermi-elfabg-accept-clobber
Sep 13, 2026
Merged

Stop the payload content type from clobbering a modeled Accept header#220
aaylward merged 1 commit into
mainfrom
claude/kind-fermi-elfabg-accept-clobber

Conversation

@aaylward

Copy link
Copy Markdown
Collaborator

What

Reported from use against a generated client. An operation with a response @httpPayload emits that payload's content type as the request's Accept header — but with an unconditional Set, after the @httpHeader bindings have already written the caller's own modeled Accept:

request.headers.Set("accept", input.accept);              // the modeled member
...
request.headers.Set("accept", "application/octet-stream"); // overwrites it, every call

So a modeled @httpHeader("Accept") member was dead on arrival, and the workaround left to a consumer is an interceptor re-setting the header in ModifyBeforeTransmit — which is the client's own job.

The payload content type is a default, not an override, so it is now applied only when nothing has already set the header:

if (!request.headers.Get("accept").has_value())
  request.headers.Set("accept", "application/octet-stream");

That is the same guard two neighbours already had, which is what makes this a miss rather than a design choice:

  • the generated Send helper, on its document-response default — "Operations with a non-document response payload set their own accept";
  • HttpBindingCodeGen.writePayloadWrite, on the request payload's Content-Type.

Accept was the one default in this file without it.

I checked the other unguarded Set("content-type", ...) sites and left them alone deliberately: those write the content type of a document body the generator just encoded, so it is a fact about bytes it wrote rather than a caller preference, and the error-body helper builds a fresh response with no modeled member to lose.

Testing

Both levels, each written red first and mutation-checked by reverting the emitter to the unguarded form:

  • GeneratedCodeShapeTest.aModeledAcceptHeaderSurvivesThePayloadContentType — pins the emitted guard, and asserts per line that the bare unguarded Set is gone (a substring search would always find it inside the guarded statement).
  • examples/bazel-consumer/response_sink_acceptance_test.cc — a string pin is not proof the header reaches the wire, so ModeledAcceptHeaderTest injects a recording transport into a generated client and reads back what it actually sent: a modeled Accept survives (and appears exactly once), and an unset one still gets the payload content type — so guarding the default is not mistaken for deleting it. DownloadInput gains the modeled member; no handler changes.

Goldens move for every operation with a response payload — the diff is the guard and nothing else. Behavior changes only for a model that binds Accept, or an @httpPrefixHeaders map carrying it.

bazel test --config=werror //... — 130/130. Consumer module, same flags (minus the Beast targets a download-blocking proxy cannot run) — 14/14. gradle test spotlessCheck green. clang-format and buildifier clean.

Checklist

  • Tests added/updated for the change
  • bazel test //... and (cd codegen && gradle build spotlessCheck) pass locally
  • Formatting clean (clang-format, buildifier, spotless)
  • Architectural decisions recorded as an ADR (if applicable) — n/a, this restores the intended behavior rather than changing a decision

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ


Generated by Claude Code

Found in use against a generated client. An operation with a response
@httpPayload emits that payload's content type as the request's Accept
header, but did so with an unconditional Set — after the @HTTPHeader
bindings had already written the caller's own modeled Accept. The modeled
member was dead on arrival: set, then overwritten, on every call. The
workaround left to a consumer is an interceptor re-setting the header in
ModifyBeforeTransmit, which is the client's own job.

The payload content type is a default, not an override, so it is now applied
only when nothing has already set the header. That is the same guard the
generated Send helper has always applied to its document-response default
("Operations with a non-document response payload set their own accept"),
and the one writePayloadWrite already applied to the request payload's
Content-Type — Accept was the one default in this file that missed it.

Covered at both levels, each written red first and mutation-checked against
the unguarded emitter: a codegen pin on the emitted line, and a wire-level
acceptance test that injects a recording transport into a generated client
and reads back the header it actually sent — with the unset case asserted
too, so guarding the default is not mistaken for deleting it.

Goldens move for every operation with a response payload; behavior changes
only for a model that binds Accept, or an @httpPrefixHeaders map carrying it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
@cursor

cursor Bot commented Sep 13, 2026

Copy link
Copy Markdown

Reviewed f11b292. The guarded default preserves modeled and prefix-provided Accept values while retaining the payload media type when none is supplied. Case-insensitive lookup, empty-value behavior, generated goldens, and wire-level coverage all look sound. No actionable findings.

@aaylward
aaylward merged commit b829d71 into main Sep 13, 2026
16 checks passed
@aaylward
aaylward deleted the claude/kind-fermi-elfabg-accept-clobber branch September 13, 2026 05:53
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