Skip to content

Intern known request header names to static AsciiString - #2206

Merged
hyperxpro merged 1 commit into
mainfrom
header-name-interning
Jun 20, 2026
Merged

hyperxpro merged 1 commit into
mainfrom
header-name-interning

Conversation

@hyperxpro

Copy link
Copy Markdown
Member

Motivation:

When building the Netty request, well-known header names were passed as plain Strings, so Netty re-hashed and re-encoded them on every request instead of reusing cached AsciiString constants.

Modification:

Intern recognised header names to static AsciiString constants, applied only on a byte-identical lowercase match so the bytes put on the wire are unchanged.

Result:

Faster header encoding on the request path (it hits Netty's bulk-copy branch), with on-wire output unchanged.

@hyperxpro
hyperxpro merged commit 02aa247 into main Jun 20, 2026
29 of 30 checks passed
@hyperxpro
hyperxpro deleted the header-name-interning branch June 20, 2026 23:56
hyperxpro pushed a commit that referenced this pull request Nov 6, 2026
## 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
(#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](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant