Skip to content

fix: reject a request body on a body-forbidden method (GET/HEAD/TRACE)#135

Merged
OmarAlJarrah merged 3 commits into
mainfrom
fix/request-body-on-forbidden-method
Jun 22, 2026
Merged

fix: reject a request body on a body-forbidden method (GET/HEAD/TRACE)#135
OmarAlJarrah merged 3 commits into
mainfrom
fix/request-body-on-forbidden-method

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jun 17, 2026

Copy link
Copy Markdown
Member

Problem

The Request model made body optional for every method and ran no method/body compatibility check, so a GET (or HEAD/TRACE) request carrying a body was publicly constructible. The two reference transports then handled that case inconsistently, and neither was correct:

  • OkHttp passed the body to Request.Builder.method("GET", body), which throws IllegalArgumentException("method GET must not have a request body."). That unchecked exception escaped the transport's @Throws(IOException) contract and the retry pipeline.
  • JDK built a BodyPublisher from the body and then called builder.GET(), which ignores it. The body was silently dropped — and for a small body the eager publisher path had already drained a consume-once writeTo into a byte array that was then discarded.

This is the inverse of the body-less POST/PUT/PATCH case fixed in #82: a body present on a method that forbids one, rather than a body absent on a method that requires one.

Change

Reject at construction, so the two transports never have to disagree:

  • Method now carries a permitsRequestBody flag — false for GET, HEAD, TRACE, and CONNECT; true for the rest. The KDoc cites the relevant RFC 9110 sections.
  • Request.RequestBuilder.build() throws IllegalArgumentException when a body is set on a method whose permitsRequestBody is false, with a message that names the method and points at the body-permitting alternatives.
  • The JDK adapter now sends BodyPublishers.noBody() unconditionally for GET/HEAD/TRACE rather than adapting (and discarding) the body — there is no body to adapt, so nothing is consumed for nothing.
  • The OkHttp adapter is unchanged in behavior; its KDoc now records that the crash path is unreachable because core rejects the body before dispatch.

Rationale

Failing fast at the model boundary is the single consistent policy: OkHttp can no longer receive a body it would crash on, and the JDK transport no longer consumes a single-use body it never sends. The error is raised once, at build time, with a clear message — rather than surfacing differently (or not at all) depending on which transport is wired in.

Public API

Two changes, both source- and binary-compatible:

  • New Method.permitsRequestBody getter — the only change reflected in the sdk-core api snapshot (regenerated and committed).
  • Request.RequestBuilder.body now accepts RequestBody? rather than RequestBody, so body(null) clears a body carried over by newBuilder() — the supported way to downgrade a body-carrying request to a body-forbidden method. Widening the parameter to nullable leaves the JVM descriptor unchanged, so it does not appear in the api snapshot and existing callers compile unchanged.

Tests

  • sdk-core: build rejects a body on GET/HEAD/TRACE, allows it on POST/PUT/PATCH/DELETE/OPTIONS, allows a body-less forbidden method, and rejects a newBuilder that switches a body-carrying request to GET; plus a permitsRequestBody flag check in MethodTest.
  • Both transport suites: symmetric tests that a body on a forbidden method is rejected before dispatch and that a body-less GET dispatches with no body. The OkHttp async-adaptation-failure test now triggers the failure via a non-http(s) URL scheme (which OkHttp rejects) since GET-with-body can no longer be built.

Gated build commands run (all passed)

  • ./gradlew :sdk-core:test :sdk-core:ktlintCheck :sdk-core:detekt :sdk-core:apiCheck --no-daemon
  • ./gradlew :sdk-transport-okhttp:test :sdk-transport-okhttp:ktlintCheck :sdk-transport-okhttp:detekt :sdk-transport-okhttp:apiCheck --no-daemon
  • ./gradlew :sdk-transport-jdkhttp:test :sdk-transport-jdkhttp:ktlintCheck :sdk-transport-jdkhttp:apiCheck --no-daemon (detekt is skipped on this module)

Closes #117

@OmarAlJarrah OmarAlJarrah changed the title Reject a request body on a body-forbidden method (GET/HEAD/TRACE) fix: reject a request body on a body-forbidden method (GET/HEAD/TRACE) Jun 17, 2026
A `Request` could be built with a body on a method that forbids one (GET,
HEAD, TRACE). The two transports then handled it inconsistently and neither
was correct:

- OkHttp's `Request.Builder.method("GET", body)` throws
  `IllegalArgumentException("method GET must not have a request body.")`.
  That unchecked exception escaped the transport's `@Throws(IOException)`
  contract (and the retry pipeline).
- The JDK transport built a `BodyPublisher` from the body and then called
  `builder.GET()`, which ignores it. The body was silently dropped — and for
  a small body the eager publisher path had already drained a consume-once
  `writeTo` into a byte array that was then discarded.

Pick one consistent policy and reject at construction. `Method` now carries a
`permitsRequestBody` flag (`false` for GET/HEAD/TRACE/CONNECT), and
`Request.RequestBuilder.build()` throws `IllegalArgumentException` when a body
is set on a method that forbids one. Failing fast at the model boundary keeps
both transports from diverging: OkHttp can no longer receive a body it would
crash on, and the JDK transport no longer consumes a body it never sends. The
JDK adapter now sends `noBody()` unconditionally for GET/HEAD/TRACE instead of
adapting (and discarding) the publisher.

Adds symmetric coverage in both transport suites plus core tests for the new
validation and the `permitsRequestBody` flag. The new `Method.permitsRequestBody`
getter is the only public-API addition (api snapshot regenerated).

Closes #117
…NECT

RequestBuilder.body now accepts null, so a body-carrying request can be
downgraded to a body-forbidden method (GET/HEAD/TRACE/CONNECT) via newBuilder
by clearing the body first — previously there was no way to drop it. build()
also runs the required-field checks before the body/method compatibility check,
so a missing method or url is reported before a body conflict.

Document that CONNECT, like GET/HEAD/TRACE, is rejected a request body at build
time — across Method, Request, and both transport adapters — and correct the
RFC 9110 wording: only TRACE carries a hard "MUST NOT send content" (§9.3.8),
while GET/HEAD/CONNECT payloads merely have no generally defined semantics.
Adds tests for the body(null) clear, the newBuilder downgrade recovery path,
and CONNECT coverage in the core and transport suites.
@OmarAlJarrah OmarAlJarrah force-pushed the fix/request-body-on-forbidden-method branch from a6b2fef to 8712cb3 Compare June 22, 2026 02:12
The build() rejection message listed only POST/PUT/PATCH as the
body-permitting alternatives, which read as if DELETE and OPTIONS were
also forbidden a body. List the full permitted set so the message
matches Method.permitsRequestBody.
@OmarAlJarrah OmarAlJarrah merged commit 8399a52 into main Jun 22, 2026
1 check passed
@OmarAlJarrah OmarAlJarrah deleted the fix/request-body-on-forbidden-method branch June 22, 2026 02:29
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.

Transports mishandle a request body set on a body-forbidden method (GET/HEAD)

1 participant