Skip to content

Skip redirect set lookup for non-3xx responses - #2301

Merged
hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/avoid-boxing-on-redirect-status-check
Aug 5, 2026
Merged

Skip redirect set lookup for non-3xx responses#2301
hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/avoid-boxing-on-redirect-status-check

Conversation

@pavel-ptashyts

@pavel-ptashyts pavel-ptashyts commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Interceptors.exitAfterIntercept runs for every HTTP response and tested the
redirect status like this:

if (Redirect30xInterceptor.REDIRECT_STATUSES.contains(statusCode)) {

REDIRECT_STATUSES is a Set<Integer> and statusCode is an int, so the
call autoboxes. Integer.valueOf caches -128..127, which covers 100..103 but
nothing else a response carries, so every 2xx / 4xx / 5xx response allocated a
fresh Integer and hashed it, only to answer false. Those are almost all of
the traffic.

Change

Move the test into a package-private Redirect30xInterceptor.isRedirect(int),
next to the set it guards, and gate the set lookup behind Netty's
HttpStatusClass.REDIRECTION.contains(int). That check takes a primitive, so
only a genuine 3xx pays for the boxing.

The set is still consulted rather than inlined as a switch over the five
known codes. REDIRECT_STATUSES is public and a mutable HashSet; changing
what it means is out of scope for a performance change, so any 3xx a caller
registered keeps working. Behaviour differs only for a non-3xx code added to
the set, which would not be a redirect status.

No public API change.

Scope

One predicate, its two call sites, and a unit test. Related per-response
allocations found in the same method (responseHeaders.getAll(SET_COOKIE)
allocates a LinkedList per response even with no Set-Cookie present) are
left for a separate PR.

Tests

Redirect30xInterceptorTest covers isRedirect(int) directly: the five
followed statuses, the 3xx that are not followed (300, 304, 305, 306, 399 -
304 above all, which must reach the normal response path), and non-3xx codes.

The redirect paths themselves are already covered by Relative302Test,
PerRequestRelative302Test, PostRedirectGetTest, RedirectBodyTest,
HttpToHttpsRedirectTest, RedirectCredentialSecurityTest,
RedirectConnectionUsageTest, Head302Test,
StripAuthorizationOnRedirectHttpTest and ws.RedirectTest.

Verification

Caveat on the testing gate: AGENTS.md requires the build to run on JDK 11 and
no JDK 11 is installed on this machine, so it was run on JDK 17 (also in the
CI matrix). The JDK 11 leg of CI on this PR is the real gate.

Follows the same review pass as #2300.

Claude Code on behalf of @pavel-ptashyts

🤖 Generated with Claude Code

Redirect30xInterceptor.REDIRECT_STATUSES is a Set<Integer>, so the
membership test in Interceptors.exitAfterIntercept autoboxed the int
status code. HTTP status codes are all above 127 and therefore outside
the range Integer.valueOf caches, so every response allocated a fresh
Integer and hashed it, only for the answer to be false on the 2xx, 4xx
and 5xx responses that make up almost all traffic.

Guard the lookup with a 300..399 range check. The set is public and
mutable, so it is deliberately still consulted rather than inlined as a
switch over the five known codes: a caller that registered an extra 3xx
status keeps having it honoured, and only a genuine redirect now pays
for the boxing.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +128 to +131
// Range check first: REDIRECT_STATUSES is a Set<Integer>, so contains(statusCode) boxed a fresh
// Integer on every response (status codes are outside Integer's valueOf cache). The set is public
// and mutable, so the lookup is kept rather than inlined as a switch, and a caller that registered
// an extra 3xx status still has it honoured.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we drop this one? It describes the change rather than the code, and once the old version is out of memory it just reads as noise. It is also four lines of prose for a one line predicate, where the rest of the file only comments invariants. If you want something here, one line does it: only a 3xx can be a redirect, so the range check keeps the boxed lookup off the common path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped. It was describing the change rather than the code, and with the predicate moved into Redirect30xInterceptor the call site reads on its own, so there is nothing left worth saying there.

// Integer on every response (status codes are outside Integer's valueOf cache). The set is public
// and mutable, so the lookup is kept rather than inlined as a switch, and a caller that registered
// an extra 3xx status still has it honoured.
if (statusCode >= 300 && statusCode < 400 && Redirect30xInterceptor.REDIRECT_STATUSES.contains(statusCode)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Netty already has this one. HttpStatusClass.REDIRECTION.contains(statusCode) takes an int and uses the same 300/400 bounds. We do not use HttpStatusClass anywhere else yet, so take it or leave it, the explicit range reads fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken. REDIRECTION is built with 300/400 bounds and contains takes an int, so it keeps the boxing off non-3xx responses exactly as the open-coded range did, and it names what the bounds mean instead of leaving two magic numbers. First use of HttpStatusClass in the codebase, but that seems like a reason to start rather than not to.

// Integer on every response (status codes are outside Integer's valueOf cache). The set is public
// and mutable, so the lookup is kept rather than inlined as a switch, and a caller that registered
// an extra 3xx status still has it honoured.
if (statusCode >= 300 && statusCode < 400 && Redirect30xInterceptor.REDIRECT_STATUSES.contains(statusCode)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor: the range now lives here while the set lives in Redirect30xInterceptor, so the knowledge is split across two classes. A static isRedirect(int) next to the set would keep it in one place and give you something to test. Fine to skip if you want the diff minimal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Package-private isRedirect(int) next to the set, so Interceptors just asks the question and the range and the statuses stay together.

Added Redirect30xInterceptorTest for it. The case that earns the test is 304: in the 3xx class, not a followed redirect, so it has to be rejected exactly like a non-3xx status. Neither half of the predicate says that on its own.

Review feedback on AsyncHttpClient#2301. The range check lived in Interceptors while
the statuses it guards live in Redirect30xInterceptor, splitting one
decision across two classes. Move it into a package-private
isRedirect(int) beside the set, so Interceptors reads as a question
about redirects and the knowledge stays in one place.

Use Netty's HttpStatusClass.REDIRECTION for the class check rather than
an open-coded 300..400: it takes an int, so it still keeps the boxing
lookup off non-3xx responses, and it names what the bounds mean.

Drop the comment at the call site. It described the previous version of
the code rather than the code, and the predicate now says what it does.

The new test covers the part that is not obvious from either half on
its own: a 3xx that is not a followed redirect, 304 above all, has to
be rejected exactly like a non-3xx status.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hyperxpro
hyperxpro merged commit 5454364 into AsyncHttpClient:main Aug 5, 2026
13 checks passed
hyperxpro added a commit that referenced this pull request Aug 5, 2026
## Problem

`AsyncHttpClientHandler` requested a read from both lifecycle callbacks:

```java
public void channelActive(ChannelHandlerContext ctx)       { ctx.read(); }
public void channelReadComplete(ChannelHandlerContext ctx) { ctx.read(); }
```

Netty's `HeadContext` already calls `Channel#read()` immediately after
firing
either event whenever `autoRead` is on. AsyncHttpClient never clears
`autoRead`
(no `ChannelOption.AUTO_READ` anywhere in the codebase) and Netty
defaults it to
on, so that was the path for every connection: each read cycle traversed
the
outbound pipeline and reached `doBeginRead` twice instead of once. The
cost is
per read cycle, so it scales with how many reads a response takes.

## Change

Drive the read from the handler only when `autoRead` is off.

That is also a fix in its own right: a caller who disabled `autoRead`
through
`setChannelOption` previously had the setting silently defeated by these
two
unconditional reads, since the handler kept requesting reads regardless.

## Verification of the Netty behaviour

Checked against `netty-codec-http2` / `netty-transport`
**4.2.16.Final**, the
version this project builds against, rather than assumed:

* `DefaultChannelPipeline$HeadContext.channelActive` and
`.channelReadComplete`
  both call `readIfIsAutoRead()`, which is
  `if (channel.config().isAutoRead()) channel.read();`
* `DefaultChannelConfig`'s constructor initialises `autoRead` to `1`, so
on.
* HTTP/2 stream channels inherit both behaviours:
  `AbstractHttp2StreamChannel$2 extends DefaultChannelPipeline`, and
`Http2StreamChannelConfig extends DefaultChannelConfig` without
overriding
  `isAutoRead`.

The last point matters because this is the shared base class of
`HttpHandler`,
`WebSocketHandler` and `Http2Handler`, and neither of the two callbacks
is
overridden by any of them, so the change applies to HTTP/1.1, WebSocket
and
HTTP/2 stream channels alike.

## Verification of the build

`mvnw clean verify` - BUILD SUCCESS, 1371 tests, 0 failures, 0 errors,
19 skipped. Error Prone, NullAway and Revapi all clean.

The suites covering the paths most exposed to a change in read behaviour
are
green: 168 HTTP/2 tests (`BasicHttp2Test`,
`Http2MultiplexBugRegressionTest`,
`Http2StreamingBodyFlowControlTest`, `Http2StreamOrphanRegressionTest`,
`Http2ConformanceRegressionTest` and the rest) and 36 WebSocket tests
(`TextMessageTest`, `ByteMessageTest`, `CloseCodeReasonMessageTest`,
`WebSocketWriteFutureTest`, `ws.ProxyTunnellingTest`).

Caveat on the testing gate: `AGENTS.md` requires the build to run on JDK
11 and
no JDK 11 is installed on this machine, so it was run on **JDK 17**
(also in the
CI matrix). The JDK 11 leg of CI on this PR is the real gate.

No public API change. Same review pass as #2300 and #2301.

Claude Code on behalf of @pavel-ptashyts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com>
hyperxpro added a commit that referenced this pull request Aug 5, 2026
## Problem

For the default handler (`executeRequest(request)` ->
`AsyncCompletionHandlerBase`
-> `Response`) the body is copied three times:

1. `HttpHandler.handleChunk` -> `EagerResponseBodyPart` copies each
chunk out of
the network buffer into a heap `byte[]` (needed: `channelRead` releases
the
   message afterwards);
2. `NettyResponse.getResponseBodyAsByteBuffer` concatenates every part
into a
   freshly allocated array;
3. `getResponseBody(charset)` decodes that array.

Step 2 is pure waste when there is only one part, which is the case for
any body
that lands in a single socket read: it copies a single part into a new
array with
nothing to concatenate it with.

## Change

`getResponseBody(Charset)` decodes straight from the part when there is
exactly
one of them. The array does not escape the method, so the part's own
array can be
decoded in place.

Several parts are still concatenated before decoding, never decoded one
at a
time, because a multi-byte character can straddle a part boundary.

`getResponseBodyAsBytes` and `getResponseBodyAsByteBuffer` are
deliberately left
untouched: they hand the array to the caller, so they keep making a
defensive
copy rather than expose a part's own array. There is no aliasing change
anywhere
in this PR.

## Measurements

Rough probe on JDK 17 over a single-part ASCII body,
concatenate-then-decode
versus decode-in-place. Not JMH, so read the shape rather than the
digits:

| body | before | after |
|------|--------|-------|
| 512 B | 496 ns | 47 ns |
| 4 KB | 2277 ns | 373 ns |
| 16 KB | 2963 ns | 1450 ns |
| 128 KB | 24913 ns | 12248 ns |

Plus one fewer whole-body allocation per response. The percentages look
large
partly because a pure-ASCII body decodes through a JDK intrinsic, which
makes the
removed copy a big share of what is left.

## Tests

Two added to `NettyAsyncResponseTest`:

* `testGetResponseBodyDecodesOnePartAndSplitPartsIdentically` splits the
two-byte
UTF-8 encoding of U+00E9 across two parts and asserts one-part and
split-part
bodies decode alike. This pins the constraint the comment states: it
fails if
  anyone later makes the multi-part path decode part by part.
* `testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray` pins that
`getResponseBodyAsBytes` still returns a fresh array and never the
part's own.

The body bytes are built as an explicit `byte[]` rather than a string
literal to
keep the source ASCII per `AGENTS.md`.

## Verification

`mvnw clean verify` - BUILD SUCCESS, 1373 tests (1371 before, plus these
two),
0 failures, 0 errors, 19 skipped. Error Prone, NullAway and Revapi
clean.
`LargeResponseTest`, `NoNullResponseTest`,
`BodyDeferringAsyncHandlerTest` and
`RedirectBodyTest`, which exercise the multi-part path, are green.

Caveat on the testing gate: `AGENTS.md` requires the build to run on JDK
11 and
no JDK 11 is installed on this machine, so it was run on **JDK 17**
(also in the
CI matrix). The JDK 11 leg of CI on this PR is the real gate.

## Not in scope

The multi-part case still concatenates. A
`CompositeByteBuf.toString(charset)`
variant measured faster there (Netty decodes a multi-component buffer
through a
recycled, un-zeroed thread-local array instead of a fresh `byte[]`), but
it
regressed at high part counts in the same probe, so it needs proper
benchmarking
before it becomes a change. Removing copy 1 would mean retaining network
buffers
and giving `Response` a lifecycle, which is public API and wants a
design
discussion first.

No public API change here. Same review pass as #2300, #2301 and #2302.

Claude Code on behalf of @pavel-ptashyts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com>
@pavel-ptashyts
pavel-ptashyts deleted the fix/avoid-boxing-on-redirect-status-check branch August 6, 2026 07:39
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