Skip to content

Set request header values without per-call copies - #2380

Merged
hyperxpro merged 1 commit into
AsyncHttpClient:mainfrom
maygemdev:claude/header-value-allocations-db4d08
Nov 6, 2026
Merged

hyperxpro merged 1 commit into
AsyncHttpClient:mainfrom
maygemdev:claude/header-value-allocations-db4d08

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Summary

NettyRequestFactory set three header values in forms that cost work on every HTTP/1.1 request:

  • A body-less POST, PUT or PATCH set Content-Length from an Integer constant. Netty's header value converter calls toString(), so every such request allocated a new "0" string.
  • A body's length was boxed into a Long before the same conversion.
  • The configured User-Agent is a String, so HttpHeadersEncoder.writeAscii encoded it char by char through setCharSequence instead of the AsciiString bulk copy. HeaderEncodeBenchmark measured the same effect for header names (Intern known request header names to static AsciiString #2206).

Changes:

  • Content-Length: 0 uses HttpHeaderValues.ZERO.
  • A body length that fits in an int goes through setInt. HttpHeaders has no setLong, so larger lengths keep the old path.
  • The User-Agent is converted to an AsciiString once, in the constructor.

Behaviour notes

  • The bytes on the wire are unchanged for all three headers. AsciiString maps chars the same way the String encode path does: Latin-1 chars become their own byte, and anything above U+00FF becomes ?. A test pins this.
  • The validating request headers still reject an illegal User-Agent (CR, LF and the like) with the same IllegalArgumentException, on every request, as before.
  • The User-Agent is now read from the config once per factory, the way the cookie encoder already is. Only a custom AsyncHttpClientConfig whose getUserAgent() changes between calls would notice. DefaultAsyncHttpClientConfig is immutable.
  • No public API change: Revapi passes.

Benchmark

New RequestHeaderValueBenchmark: builds and encodes a request with the factory's own headers, with each value in its old and new form. JMH 1.37, JDK 17.0.2, Windows, -prof gc, 3 forks, 5 x 1 s warmup, 10 x 1 s measurement.

Case Before After
Body-less POST, Content-Length: 0 817.5 B/op 769.5 B/op
POST with a body, Content-Length: 1536 841.5 B/op 817.5 B/op
Writing the User-Agent value (writeAscii branch only) 21.3 ± 6.2 ns/op 11.1 ± 2.6 ns/op

The host was 75-92% busy, so whole-request times (~800 ns/op) stayed within noise. Allocation is the reliable figure. The User-Agent gain was measured on the encoder's two branches in isolation. Neither branch allocates.

Tests

NettyRequestFactoryHeaderValuesTest encodes the factory's requests with HttpRequestEncoder and checks:

  • the default User-Agent is sent as configured;
  • a non-ASCII User-Agent produces the same bytes as the String value did;
  • a User-Agent with CRLF is still rejected on every request;
  • a caller-set User-Agent wins over the configured one;
  • Content-Length: 0 is sent for body-less POST/PUT/PATCH, and no Content-Length for GET;
  • body lengths, including one above Integer.MAX_VALUE.

Verification

  • mvnw.cmd -B -ntp clean verify -Dgpg.skip=true on JDK 17.0.2 (Windows): 1,919 tests, 0 failures, 0 errors, 35 skipped. Error Prone, NullAway and Revapi passed. Local verify ran on JDK 17 because there is no JDK 11 on this machine; CI covers JDK 11. -Dgpg.skip is set only because gpg is not installed here.
  • RedirectConnectionUsageTest timed out once under load. It fails the same way on unchanged main and passed in the next runs.

Claude Code on behalf of Pavel Ptashyts

🤖 Generated with Claude Code

NettyRequestFactory added three header values in forms that cost work
on every request. A body-less POST, PUT or PATCH set Content-Length
from an Integer, which Netty turns into a new "0" String each time. A
body's length was boxed into a Long before the same conversion. The
configured User-Agent was a String, so HttpHeadersEncoder took its
char-by-char setCharSequence path instead of the AsciiString bulk
copy.

Use HttpHeaderValues.ZERO, setInt for lengths that fit in an int
(HttpHeaders has no setLong, so larger lengths keep the old path),
and convert the User-Agent to an AsciiString once in the constructor.
The bytes on the wire are unchanged: AsciiString maps chars the same
way the String path does, and the validating request headers still
reject an illegal User-Agent on every request. The value is now read
from the config once per factory, as the cookie encoder already is.

RequestHeaderValueBenchmark, JMH 1.37, JDK 17.0.2, 3 forks, -prof gc:
- Content-Length 0: 817.5 -> 769.5 B/op
- body Content-Length: 841.5 -> 817.5 B/op
- User-Agent value write: 21.3 +- 6.2 -> 11.1 +- 2.6 ns/op, no alloc
Whole-request times were within noise on a loaded host.

Local `mvnw clean verify` ran on JDK 17 (no JDK 11 on the host): 1865
tests, 0 failures; Revapi passed.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hyperxpro
hyperxpro merged commit f9eafb9 into AsyncHttpClient:main Nov 6, 2026
13 checks passed
@pavel-ptashyts
pavel-ptashyts deleted the claude/header-value-allocations-db4d08 branch November 6, 2026 19:40
pavel-ptashyts added a commit to maygemdev/async-http-client that referenced this pull request Nov 6, 2026
Keeps the branch current with main at f9eafb9 (includes AsyncHttpClient#2372 and
AsyncHttpClient#2380). Merged without conflicts.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mkurz mkurz mentioned this pull request Nov 6, 2026
6 tasks done
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