Two ways to mistranslate a retry
Contents
Correction, 2026-10-10: The first version of this post said Micronaut's
@Retryable(attempts = "2")makes 2 calls in total, that the Quarkus port'smaxRetries = 2therefore "added one extra retry", and that the fix wasmaxRetries = attempts - 1. That is backwards. Micronaut'sattemptscounts retries:attempts = "2"is 3 calls, the default"3"is 4. The original Quarkus port matched Micronaut; the June "fix" left it one call short until commit887c98b4(2026-08-25) lowered Micronaut's annotations instead. The "~50% more" and "~66% less" hit figures were arithmetic from a code review, not measurements, and are gone. The Spring bug below (5xx never retried) stands, but one more Spring claim was wrong: its retry counts were not "already correct" — like the June-"fixed" Quarkus port, Spring made one call fewer than Micronaut on every client.
Porting 10 HTTP clients from Micronaut to Quarkus and Spring Boot produced two retry mistranslations. One misread a number. The other misread a signal. Both ports passed every functional test. Both shipped with a claim of parity with the original: a code comment on the Spring side, and on the Quarkus side the commit title itself — "correct retry counts to match Micronaut original", the commit that broke the match.
A code review on 2026-06-19 caught both. It diagnosed the Spring one correctly and the Quarkus one in reverse. The reversed half survived until someone read SimpleRetry on 2026-08-25.
The reference: what Micronaut's attempts counts#
Micronaut's declarative HTTP clients carry their retry policy right on the interface. This is the July state of the benchmark repo:
@Client("science")
@Retryable(attempts = "2", delay = "500ms")
public interface SolarClient {
@Get("/api/v1/solar-flares")
SolarFlareResponse solarFlares();
}
The parameter is called attempts. Its Javadoc, in Retryable.java, says @return The maximum number of retry attempts. The interceptor settles which reading wins. SimpleRetry.canRetry runs after each failure:
return this.attemptNumber.incrementAndGet() < (maxAttempts + 1) && ((maxDelay == null) || overallDelay.get() < maxDelay.toMillis());
With attempts = "2": first failure, counter 1 < 3, retry. Second failure, 2 < 3, retry. Third failure, 3 < 3, give up. Three calls. attempts is a retry count wearing a total-count name.
The NASA clients omit attempts and set only delay and multiplier:
@Client("nasa-apod")
@Retryable(delay = "500ms", multiplier = "2.0")
public interface NasaApodClient { /* ... */ }
The default is attempts = "3": three retries, four calls, with 3.5 s of cumulative backoff. Micronaut retries on any exception, including HttpClientResponseException from HTTP 5xx. A 500 from the server is an exception. Clean.
Ten clients across five groups. Each one with a .onErrorReturn(EMPTY_*) fallback downstream, so failures degrade gracefully instead of propagating.
Bug 1: the Quarkus fix that broke parity#
MicroProfile Fault Tolerance's @Retry looked like a direct translation, and the first port treated it as one:
// June port: the number copied 1:1
@RegisterRestClient(configKey = "science")
@Retry(maxRetries = 2, delay = 500, delayUnit = ChronoUnit.MILLIS)
public interface SolarClient { /* ... */ }
maxRetries is the number of retries after the initial attempt. @Retry(maxRetries = 2) = 3 calls. Micronaut's @Retryable(attempts = "2") = 3 calls. The NASA clients went to maxRetries = 3: 4 calls against Micronaut's 4. The literal copy was right.
The review read attempts as total invocations -- the name all but asks you to -- and concluded every Quarkus client retried once too often. The fix applied the arithmetic:
// "FIXED": maxRetries = attempts - 1 (one call short of Micronaut)
@RegisterRestClient(configKey = "science")
@Retry(maxRetries = 1, delay = 500, delayUnit = ChronoUnit.MILLIS)
public interface SolarClient { /* ... */ }
From that commit on, Quarkus made 2 calls where Micronaut made 3, and 3 where Micronaut made 4. Every client, every failure path, for two months.
The NASA half of the same commit did get one thing right. MicroProfile's @Retry only does fixed delay -- there's no multiplier parameter. Exponential backoff requires SmallRye's @ExponentialBackoff:
@RegisterRestClient(configKey = "nasa-apod")
@Retry(maxRetries = 2, delay = 500, delayUnit = ChronoUnit.MILLIS)
@ExponentialBackoff // factor=2 by default, matches Micronaut's multiplier="2.0"
public interface NasaApodClient { /* ... */ }
July kept SmallRye's default 200 ms retry jitter as a thundering-herd bonus. August removed it: on the ISS client, whose delay is 0, the jitter was the delay.
How August resolved it#
Commit 887c98b4 (2026-08-25) went to the source. Its message quotes canRetry and notes that AnnotationRetryStateBuilder passes attempts through verbatim. Rather than raise Quarkus and Spring back up, it lowered Micronaut to the counts the other two ports already used:
// attempts counts RETRIES on Micronaut, not total calls: SimpleRetry.canRetry allows
// attemptNumber <= maxAttempts, so "2" here would be two retries where Quarkus's
// maxRetries=1 and Spring's RetryInterceptor(1, ...) are one.
@Retryable(attempts = "1", delay = "500ms")
"1" for ISS, Open-Meteo and the six science clients, "2" for the NASA pair. Every port now makes 2 calls per failure on most clients and 3 on the NASA pair. RetryTriggerTest (b6b90e48, 2026-08-28) points nasa-apod at a stub server on each port and asserts three requests for a 500.
Bug 2: Spring under-retried#
Spring Boot doesn't have declarative retry annotations on HTTP interfaces. The retry lives in a WebClient ExchangeFilterFunction:
// WRONG: only retries transport errors
private static ExchangeFilterFunction retryFilter(Retry retry) {
return (request, next) -> next.exchange(request)
.retryWhen(retry);
}
Three lines. Looks right. A code comment above it said "matching Micronaut's effective behaviour."
It didn't.
WebClient's exchange() emits HTTP 4xx/5xx as a normal onNext(ClientResponse), not as onError. The response is a value, not an exception. retryWhen only subscribes to error signals. It never sees a 500.
The synth mock injects three failure modes: immediate HTTP 500, 2-second-then-504, and connection reset. Micronaut retries all three -- every 5xx is an HttpClientResponseException. The Spring port retried only the connection reset (a transport error, an actual onError). Two of the three failure modes went straight to the fallback without a single retry. The fix's commit message names the cost: a different effective upstream load, and different success/fail counts persisted to survey_result.
The fix adds a flatMap that converts error responses to error signals before retryWhen can see them:
// FIXED: convert HTTP errors to onError before retry
private static ExchangeFilterFunction retryFilter(Retry retry) {
return (request, next) -> next.exchange(request)
.flatMap(resp -> resp.statusCode().isError()
? resp.createException().flatMap(Mono::error)
: Mono.just(resp))
.retryWhen(retry);
}
createException() drains the response body, so there's no connection leak. After the fix, retryWhen sees an onError(WebClientResponseException) for every 5xx -- same as Micronaut seeing HttpClientResponseException.
Spring's retry counts were never Micronaut's, either. Retry.max(1) and Retry.fixedDelay(1, 500ms) are one retry; Retry.backoff(2, 500ms) is two. Reactor counts retries -- same as MicroProfile, and, as it turns out, same as Micronaut. The numbers were one short of Micronaut's on every client. After the review, Spring and the "fixed" Quarkus agreed with each other exactly, and two ports out of three agreeing looked like confirmation.
Why both were invisible#
Every client has a fallback:
// Micronaut
solarClient.solarFlares()
.onErrorReturn(SolarFlareResponse.EMPTY)
// Quarkus (Mutiny)
solarClient.solarFlares()
.onFailure().recoverWithItem(SolarFlareResponse.EMPTY)
// Spring (Reactor)
solarClient.solarFlares()
.onErrorReturn(SolarFlareResponse.EMPTY)
One call fewer? The fallback fires a retry earlier. No retry at all? The fallback fires immediately. Either way: the response is well-formed, the HTTP 200 goes to the caller, the test assertions pass.
The only signal is quantitative, and in June nothing counted it. No test checked "given a server that fails, the client calls it exactly N times." RetryTriggerTest is that test, and it arrived in August -- after the right N had been read out of SimpleRetry, not out of the annotation's name.
The count#
| Micronaut | Quarkus | Spring WebFlux | |
|---|---|---|---|
| Science clients, June port | attempts="2" = 3 calls | maxRetries=2 = 3 calls | Retry.fixedDelay(1, 500ms) = 2 calls |
| Science clients, after 2026-06-19 "fix" | 3 calls | maxRetries=1 = 2 calls | 2 calls |
| NASA pair, June port | default attempts="3" = 4 calls | maxRetries=3 = 4 calls | Retry.backoff(2, 500ms) = 3 calls |
| NASA pair, after 2026-06-19 "fix" | 4 calls | maxRetries=2 = 3 calls | 3 calls |
| Since 2026-08-25 | attempts="1" / "2" = 2 / 3 calls | 2 / 3 calls | 2 / 3 calls |
| Retries HTTP 5xx? | Yes (exception) | Yes (exception) | Only after the 2026-06-19 fix (was onNext) |
Two lessons that look obvious in a table but were not obvious in a 10-client port:
Count base: read the interceptor, not the parameter name. Micronaut's attempts, MicroProfile's maxRetries and Reactor's Retry all count retries after the first call. The mapping was the identity all along. maxRetries = attempts - 1 was the bug, applied to ten clients in the name of fixing one. Eighteen requests for one redirect states the corrected semantics and shows what the same retry chain does to a 302.
Signal model differs. In Micronaut and Quarkus, an HTTP 5xx is an exception. In Spring WebClient, it's a successful response carrying an error status code. A retry operator that watches for exceptions will never see it. The fix is three lines of flatMap, but you have to know you need them.
The same August sweep turned up four more retry differences no annotation showed: Micronaut's ISS client inheriting a 1 s default delay, SmallRye's 200 ms jitter, Reactor's ±50% backoff jitter on the NASA clients, and reactor-netty's own extra retry on a connection reset. Count server hits. Don't trust the annotation, and don't trust the review that read it.
Filed under: java, micronaut, quarkus, spring-boot, reactive, retry
Read next#
- Eighteen requests for one redirect -- the corrected retry semantics, multiplied by redirect-following defaults into 18, 6, 3 and 1 requests.
- Four bugs that needed real Postgres to exist -- four more bugs on the same reactive ports that no test could see.
- Reactor to Mutiny: the operator phrasebook -- the rest of the annotation and operator mapping from this port.
Sources#
- micronaut-retry 5.0.2 sources -- the version the benchmark ran:
Retryable.attempts()Javadoc "The maximum number of retry attempts", default"3";SimpleRetry.canRetryreturnsattemptNumber.incrementAndGet() < (maxAttempts + 1) - micronaut-retry 4.10.9 sources -- same Javadoc and the same
canRetryline in the 4.x line - Benchmark repo commits:
887c98b4(2026-08-25, "Micronaut'sattemptscounts retries, so every client ran one extra call";5d3a6ba5on the reactive branch this post covers),baefc27d(2026-06-19, the QuarkusmaxRetries = attempts - 1change),b692c865(2026-06-19, the Spring 5xxflatMapfix),b6b90e48(2026-08-28,RetryTriggerTest) - MicroProfile
@RetryAPI --maxRetries= "The max number of the retries" - Quarkus SmallRye Fault Tolerance guide
- Baeldung: Retry in Spring WebFlux --
onStatusmaps 5xx to aServiceExceptionso the retry filter can retry 5xx only