Retry: backoff without a framework
Retry.run re-invokes a Result-returning operation until it succeeds, the error stops being worth retrying, or attempts run out. It blocks with Thread.sleep between attempts, which is just fine on virtual threads: sleeping there is cheap and needs no scheduler machinery. Cancellation is cooperative: interrupt the thread and the loop exits with InterruptedException immediately.
enum ApiError { RateLimited, InvalidApiKey }
Retry transient errors until one succeeds
A Policy is three numbers: total attempts, initial delay, backoff factor. The operation returns Result, so "failed this time" is a value, not an exception. The retry loop owns the attempt counter and hands the body the current attempt number, so you never track one in outside mutable state:
var policy = Retry.Policy.exponential(5, Duration.ZERO);
Result<ApiError, String> r = Retry.run(policy, e -> true, attempt ->
attempt < 3
? Result.err(ApiError.RateLimited)
: Result.ok("fetched on attempt " + attempt));
assertEquals(Result.ok("fetched on attempt 3"), r);
Not every error deserves a retry
The predicate looks at the typed error and decides. A rate limit is worth waiting out. A bad API key never fixes itself, so retrying it three times just adds latency to the same failure. Terminal errors surface immediately, and this example proves it in the value: a second attempt would return Ok, so the Err in the assertion is evidence that no second attempt ever ran.
var policy = Retry.Policy.fixed(5, Duration.ZERO);
Result<ApiError, String> r = Retry.run(
policy,
e -> e == ApiError.RateLimited, // only transient errors retry
attempt -> attempt == 1
? Result.err(ApiError.InvalidApiKey)
: Result.ok("a second attempt would have succeeded"));
assertEquals(Result.err(ApiError.InvalidApiKey), r);
Cap the backoff, spread the herd
Uncapped exponential backoff gets silly fast: attempt 10 of a 1-second doubling policy sleeps for eight and a half minutes. withMaxDelay bounds every delay, and withJitter scales each sleep by a random factor so a thousand clients retrying in lockstep don't hammer the service in waves. The plan itself stays inspectable: delayBefore is pure (jitter applies only at sleep time), so you can assert your policy's timing without sleeping through it.
var policy = Retry.Policy.exponential(6, Duration.ofMillis(100))
.withMaxDelay(Duration.ofMillis(400))
.withJitter(0.2); // ±20% at sleep time, not in the plan
assertEquals(Duration.ofMillis(100), policy.delayBefore(2));
assertEquals(Duration.ofMillis(200), policy.delayBefore(3));
assertEquals(Duration.ofMillis(400), policy.delayBefore(4)); // capped
assertEquals(Duration.ofMillis(400), policy.delayBefore(5)); // stays capped
Exhaustion returns the last error
When every attempt fails, you get the most recent Err back: a value you can log, map, or feed into a fallback, because the failure never stopped being data.
var policy = Retry.Policy.fixed(3, Duration.ZERO);
Result<ApiError, String> r =
Retry.run(policy, e -> true, () -> Result.err(ApiError.RateLimited));
assertEquals(Result.err(ApiError.RateLimited), r);
This page is generated from a test file. Read or improve it on GitHub.