Eighteen Requests for One Redirect
Contents
Eighteen. That is how many requests Micronaut's HTTP client sent to an upstream that had answered the first one with a single 302. Spring MVC sent 6 for the same redirect, Quarkus 3, Spring WebFlux 1. Same stub, same client interface, same one hop to follow or not. All four ports now spend exactly one request on it, and none of the eighteen ever reached a published benchmark number. Here is where they went.
One stub, one 302, four bills#
The stub is a com.sun.net.httpserver.HttpServer inside each port's RetryTriggerTest. It counts every arrival on /planetary/apod, and in redirect mode it answers like this:
if (STATUS.get() == -2) {
// A redirect back to the same path: following it would show up as a second
// request, which is exactly what the assertion counts.
exchange.getResponseHeaders().add("Location", "/planetary/apod?redirected=1");
exchange.sendResponseHeaders(302, -1);
exchange.close();
return;
}
Every port drives the same client against it, the nasa-apod one, and asks for one picture of the day:
| client stack | upstream requests for one 302 |
|---|---|
| Micronaut (Netty client) | 18 |
| Spring MVC (Apache HttpClient5) | 6 |
| Quarkus (RESTEasy Reactive / Vert.x) | 3 |
| Spring WebFlux (reactor-netty) | 1 |
Eighteen trips to be told, eighteen times, that the page moved.
Follow, then retry: the two defaults behind 18, 6, 3, 1#
A client that meets a 302 answers two questions, and nothing forces the answers to agree:
- Does it follow? Each followed hop is a fresh request.
- Does what comes back enter the retry chain? If it does, the chain multiplies every request the first question produced.
| stack | follows the 302? | retries the outcome? | requests |
|---|---|---|---|
| Micronaut | yes, up to 5 hops | yes | 18 |
| Spring MVC | yes | yes | 6 |
| Quarkus | no | yes | 3 |
| Spring WebFlux | no | no | 1 |
The fourth cell, follow without retry, is empty. Nobody in this benchmark lands there.
nasa-apod carries the same retry budget on every port: two retries, so a retried failure costs three calls, which every port's test asserts for a 500. With the retry side equal, the gap between 18 and 6 comes from the redirect side: both clients follow, and Micronaut chases the loop further before giving up.
Where each stack spends the requests#
Micronaut follows, and its retry chain multiplies every hop. HttpClientConfiguration.DEFAULT_FOLLOW_REDIRECTS is true, and the test's own javadoc records the cap: "Micronaut follows up to 5 redirects by default." The per-service switch, micronaut.http.services.<id>.follow-redirects, is inherited by ServiceHttpClientConfiguration rather than advertised on the service-client docs. Micronaut retries any exception unless a predicate says otherwise, and the redirect failure was not one the predicate refused.
Spring MVC does the same with a shorter leash. HttpClient5 installs RedirectExec in its execution chain unless the builder calls disableRedirectHandling(). The port's hand-rolled RetryInterceptor retries a 5xx or an IOException, and the comment left at the fix spells out what happened: "each hop is a fresh request the retry chain then multiplies: one 302 from the stub cost 6 upstream requests here, 18 on Micronaut, 1 on WebFlux".
Quarkus never follows, and retried the redirect it refused. quarkus.rest-client."client".follow-redirects is an Optional<Boolean> with no default, and the builder resolves the empty value to false. The 302 stays where it landed. Then, per the mapper's javadoc, "an unfollowed 302 would otherwise reach the body decoder and be retried — measured at 3 upstream requests for one 302". Three requests for a redirect nobody took.
Spring WebFlux does neither. reactor-netty leaves its followRedirectPredicate null, so the client never follows, and nothing on the retry path treated the 302 as a failure. It was spending one request per redirect before anyone looked, which makes it the only port that was right by default rather than by design.
The mock never redirects, so no published number moved#
The load generator never touches this path. The benchmark points every client at a Go mock (mock-server/main.go) whose four API handlers answer 200 through a plain writeJSON helper. Its failure injector maybeFail picks from a short menu: a 504 after a stall, a 500, or a hijacked-and-closed socket, with a 503 only as the fallback when the writer can't be hijacked. No 3xx, anywhere. The 18, 6, 3 and 1 come from a test that builds its own stub and counts arrivals, not from a run.
Before 2026-08-31 that left two liabilities, and the second still shapes how the comparison is read:
- Production. Real upstreams redirect for dull reasons: a CDN edge, an auth bounce, a trailing-slash canonicalizer. Had one of the app's upstreams started answering
302before the fix, the Micronaut port would have made eighteen calls where the WebFlux port made one, under retry, against a service already asking it to go elsewhere. - Fairness. In synth mode every client resolves to one mock host —
docker-compose-synth.ymlpoints all four services athttp://mock:8080. An amplification there doesn't spread across hosts; it stacks on the single one the whole fan-out shares. Give the mock a redirect and it would tax Micronaut's row eighteen-fold and leave WebFlux's alone. A framework comparison that only holds because the mock happens not to redirect is resting on luck.
A benchmark measures the paths it exercises. This one took a test aimed at the path the benchmark avoids.
The 4xx rule, settled three days earlier#
Part 25 covers a June code review that flagged two mirror-image retry bugs, with no test that counted calls; one of the two later turned out to be backwards. RetryTriggerTest, added on 2026-08-28, is a counting test, and it had already settled the neighboring question: which statuses deserve a retry at all. That fix landed on 2026-08-28; the redirect fix followed on 2026-08-31.
Before it, the four ports disagreed. The commit that ended it records the old state: "Micronaut retries any exception a client throws, so a 400 and a 429 each cost three calls -- two of them pointless, each holding a fan-out slot and a pool permit -- where Spring MVC's interceptor has always issued one. Quarkus and Spring WebFlux retried them too, by different mechanisms".
Today the outcome is unanimous. Every port's RetryTriggerTest asserts the same counts:
| upstream status | Micronaut | Quarkus | Spring WebFlux | Spring MVC |
|---|---|---|---|---|
| 400, 429 | 1 | 1 | 1 | 1 |
| 404 | 1, as null | 1 | 1 | 1 |
| 500, 503, dropped connection | 3 | 3 | 3 | 3 |
Only the route differs. Micronaut's CallBudget predicate refuses a retry when response.code() < 500; Quarkus maps 4xx to its own exception type so @Retry can abort on it; WebFlux filters its retry. Micronaut also turns a 404 into null before the retry decision ever runs, so its 404 count of one proves the mapping, not the rule. The test checks the returned value as well, because "a rule that merely declined to retry the 404 would give the same count."
One hole is still open, and the test says so. On Micronaut's three reactive branches an unlisted status such as 499 is asserted at 3 calls: HttpStatus.valueOf(int) throws on a code outside the enum, so the response arrives as a bare IllegalArgumentException with no status for the rule to read. The imperative branches stop it after one. The javadoc calls this "Asserted as measured rather than as wished", and the synth mock, emitting standard codes only, can't reach it.
Counter semantics deserve one more line. Micronaut's @Retryable(attempts = "2") counts retries, because SimpleRetry.canRetry allows attemptNumber <= maxAttempts, so it means three requests. Two frameworks configured for "the same" retry can differ by a whole call over what the number means.
The fix: one line per stack, four different places#
All four ports now spend one request on a 302, asserted by RetryTriggerTest.aRedirectIsNotFollowed on all eighteen framework branches:
assertEquals(1, callsFor(-2), "a 302 must not cost a second request");
The default lived somewhere different in each stack, so the fix does too:
- Micronaut:
follow-redirects: falseon every declared service inapplication.yml. - Spring MVC:
.disableRedirectHandling()on the HttpClient5 builder. - Spring WebFlux:
.followRedirect(false), already the reactor-netty default and pinned anyway: "Saying it out loud keeps it from drifting with a reactor default." - Quarkus: the non-retryable mapper now starts at 300 instead of 400.
@Override
public boolean handles(int status, MultivaluedMap<String, Object> headers) {
return status >= 300 && status < 500;
}
The mapper and its exception were renamed NonRetryableStatusMapper / NonRetryableStatusException on the way, since a 3xx is not a client error. The exception's javadoc now describes it as "An upstream response that will not become successful by asking again", which covers both.
Two polite defaults, multiplied#
Following a redirect is the polite reading of the spec. Retrying a failure is what every resilience guide tells you to do. Each team picked the reasonable answer to its own question, and where two of those answers met, one 302 became eighteen requests against a host already busy telling you to leave. Reading four sets of documentation would not have surfaced it; the docs agree with the behavior. Pointing all four clients at one stub and counting arrivals did, and the count now sits on eighteen branches as one assertion message: "a 302 must not cost a second request".
Filed under: java, micronaut, quarkus, spring-boot, benchmarks, jvm, retry
Read next#
- Two ways to mistranslate a retry — a June review's two mirror-image retry bugs: Spring really skipped 5xx, while the Quarkus "fix" was itself the off-by-one, corrected in August.
- The Throughput Was Always 115 — the 28-API fan-out workload and the 115 req/s ceiling that turned out not to be the framework.
- The Falsifier That Never Fired — the next part: a prediction about the reactive tier at 256MB, written down together with what would prove it wrong.
- Six OOMs and a Connection Pool — the same client layer, measured as a memory problem rather than a request count.
Sources#
RetryTriggerTest(src/test/java/dev/tsvinc/space/client/, on all 18 framework branches) — thecom.sun.net.httpserverstub, the per-status request counts,aRedirectIsNotFollowed, the "follows up to 5 redirects by default" javadoc, and on Micronaut's reactive branchesaStatusOutsideTheEnumIsLostOnTheReactivePath. Internal.- Micronaut
CallBudget(src/main/java/dev/tsvinc/space/client/) — theresponse.code() < 500retry refusal. Internal. - Micronaut
application.yml(src/main/resources/) —follow-redirects: falseper service;HttpClientConfiguration.DEFAULT_FOLLOW_REDIRECTS = trueconfirmed inmicronaut-http-client-core-4.10.9.jar. Internal. - Quarkus
NonRetryableStatusMapper/NonRetryableStatusException(src/main/java/dev/tsvinc/space/client/) — thestatus >= 300 && status < 500widening; the 3-for-one-302 measurement in the mapper's javadoc. Internal. - Spring
HttpClientsConfig(src/main/java/dev/tsvinc/space/config/, imperative and reactive) —disableRedirectHandling()on HttpClient5,followRedirect(false)on reactor-netty, and the 18/6/1 comments beside them. Internal. - Commits
b6b90e48(2026-08-28, "stop retrying 4xx upstream responses") andd044df50(2026-08-31, "stop following redirects, and stop retrying the ones we don't follow") — the before-and-after record for both fixes. Internal. mock-server/main.go— happy path200;maybeFailemits504,500or an abrupt close (503fallback), never a3xx. Internal.