Eighteen Requests for One Redirect

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 stackupstream 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:

  1. Does it follow? Each followed hop is a fresh request.
  2. Does what comes back enter the retry chain? If it does, the chain multiplies every request the first question produced.
stackfollows the 302?retries the outcome?requests
Micronautyes, up to 5 hopsyes18
Spring MVCyesyes6
Quarkusnoyes3
Spring WebFluxnono1

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 302 before 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.yml points all four services at http://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 statusMicronautQuarkusSpring WebFluxSpring MVC
400, 4291111
4041, as null111
500, 503, dropped connection3333

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: false on every declared service in application.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

Sources#

  • RetryTriggerTest (src/test/java/dev/tsvinc/space/client/, on all 18 framework branches) — the com.sun.net.httpserver stub, the per-status request counts, aRedirectIsNotFollowed, the "follows up to 5 redirects by default" javadoc, and on Micronaut's reactive branches aStatusOutsideTheEnumIsLostOnTheReactivePath. Internal.
  • Micronaut CallBudget (src/main/java/dev/tsvinc/space/client/) — the response.code() < 500 retry refusal. Internal.
  • Micronaut application.yml (src/main/resources/) — follow-redirects: false per service; HttpClientConfiguration.DEFAULT_FOLLOW_REDIRECTS = true confirmed in micronaut-http-client-core-4.10.9.jar. Internal.
  • Quarkus NonRetryableStatusMapper / NonRetryableStatusException (src/main/java/dev/tsvinc/space/client/) — the status >= 300 && status < 500 widening; 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") and d044df50 (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 path 200; maybeFail emits 504, 500 or an abrupt close (503 fallback), never a 3xx. Internal.