Retry: Patience as Policy

What You'll Learn

  • How to configure retry policies with different backoff strategies
  • When to use fixed, exponential, linear, or jittered delays
  • How to filter retries by exception type
  • How to monitor retry attempts with RetryEvent
  • How retry integrates with VTask and every Path carrier (IOPath, VTaskPath, VResultPath, and the eager EitherPath)
  • Why a business Left is never retried, and how to opt selected transient errors into retry

Networks are unreliable. Services restart. Databases hiccup during failover. Most of these failures are transient: the same request that failed at 14:32:07.003 would have succeeded at 14:32:07.250. A retry policy encodes the belief that patience will be rewarded, whilst also setting a limit on how much patience to exercise.

RetryPolicy

RetryPolicy is an immutable configuration object that describes how to retry: how many times, how long to wait, and which failures are worth retrying.

Factory Methods

// Fixed delay: same wait between every attempt
RetryPolicy fixed = RetryPolicy.fixed(3, Duration.ofMillis(100));
// Waits between the 3 attempts: 100ms, 100ms (no wait after the final attempt)

// Exponential backoff: doubling delays
RetryPolicy exponential = RetryPolicy.exponentialBackoff(5, Duration.ofSeconds(1));
// Waits between the 5 attempts: 1s, 2s, 4s, 8s
// (the factory caps delays at 5 minutes; adjust with withMaxDelay, below)

// Exponential with jitter: randomised to prevent thundering herd
RetryPolicy jittered = RetryPolicy.exponentialBackoffWithJitter(5, Duration.ofSeconds(1));
// Waits: 1s, then random in [0, 2s], [0, 4s], [0, 8s]
// (the first delay is fixed; full jitter applies from the second)

// Linear backoff: delays increase by a fixed increment
RetryPolicy linear = RetryPolicy.linear(5, Duration.ofMillis(200));
// Waits between the 5 attempts: 200ms, 400ms, 600ms, 800ms

// No retry: fail immediately
RetryPolicy none = RetryPolicy.noRetry();

Choosing a Backoff Strategy

    Fixed           Exponential        Exponential         Linear
    (predictable)   (aggressive)       + Jitter            (gentle)
                                       (distributed)

    ──X──X──X──     ──X─X──X────X──    ──X─X───X──X────   ──X──X───X────X──
      │  │  │         │ │  │    │        │ │   │  │         │  │   │    │
    100 100 100     100 200 400 800    ~100 ~200 ~400 ~800  200 400 600 800
    ms  ms  ms      ms  ms  ms  ms     ms   ms   ms   ms   ms  ms  ms  ms
StrategyBest forRisk
FixedKnown recovery time (e.g., lock contention)Can overwhelm a recovering service
ExponentialUnknown recovery timeSlow convergence for quick recoveries
Exponential + JitterMultiple clients retrying the same serviceSlightly less predictable
LinearGentle ramp-up, moderate recovery timesSlower backoff than exponential

Configuration

Policies are immutable. Configuration methods return new instances:

RetryPolicy policy = RetryPolicy.exponentialBackoff(5, Duration.ofMillis(100))
    .withMaxDelay(Duration.ofSeconds(30))   // Cap the maximum wait
    .retryOn(IOException.class);             // Only retry I/O errors

Custom Retry Predicates

RetryPolicy selective = RetryPolicy.fixed(3, Duration.ofMillis(100))
    .retryIf(ex ->
        ex instanceof IOException
        || ex instanceof TimeoutException
        || (ex instanceof HttpException http && http.statusCode() >= 500));

The Builder

For complex policies, the builder offers full control:

RetryPolicy policy = RetryPolicy.builder()
    .maxAttempts(5)
    .initialDelay(Duration.ofMillis(100))
    .backoffMultiplier(2.0)
    .maxDelay(Duration.ofSeconds(30))
    .useJitter(true)
    .retryOn(IOException.class)
    .onRetry(event -> log.warn("Retry #{}: {}",
        event.attemptNumber(), event.lastException().getMessage()))
    .build();

Using Retry

Direct Execution

The Retry utility class executes an operation immediately with retry:

String response = Retry.execute(policy, () -> httpClient.get(url));

// Convenience methods
String fast = Retry.withExponentialBackoff(3, Duration.ofMillis(100),
    () -> httpClient.get(url));

String fixed = Retry.withFixedDelay(3, Duration.ofMillis(100),
    () -> httpClient.get(url));

VTask-Native Retry

For lazy, composable retry, use Retry.retryTask():

// Wrap any VTask with retry
VTask<String> resilient = Retry.retryTask(
    VTask.of(() -> httpClient.get(url)),
    RetryPolicy.exponentialBackoffWithJitter(3, Duration.ofMillis(200))
        .retryOn(IOException.class));

// Simple form with default exponential backoff
VTask<String> resilient2 = Retry.retryTask(
    VTask.of(() -> httpClient.get(url)),
    RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)));

Both forms return a lazy VTask. Nothing executes until you call run(), runSafe(), or runAsync().

Retry with Fallback

VTask<String> withFallback = Retry.retryTaskWithFallback(
    VTask.of(() -> httpClient.get(url)),
    RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)),
    lastError -> "default response");

Retry with Recovery Task

VTask<String> withRecovery = Retry.retryTaskWithRecovery(
    VTask.of(() -> primaryService.get(url)),
    RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)),
    lastError -> VTask.of(() -> backupService.get(url)));

Path-Native Retry

Retry wraps a computation. On the lazy Path carriers (where the computation has not yet run), withRetry chains as an instance method:

// IOPath
IOPath<Response> resilient = Path.io(() -> httpClient.get(url))
    .withRetry(RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)));

// VTaskPath
VTaskPath<Response> resilient = Path.vtask(() -> httpClient.get(url))
    .withRetry(RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)));

// VResultPath: async with a typed error channel
VResultPath<OrderError, Reservation> resilient =
    Path.vresultDefer(() -> inventoryService.reserve(order))
        .withRetry(RetryPolicy.exponentialBackoffWithJitter(3, Duration.ofMillis(200)));

EitherPath is an eager carrier: by the time an instance exists, the computation has already run, so an instance-chained retry would have nothing left to protect. On EitherPath the same with* vocabulary is therefore static, taking the step as a Supplier, applied at the point where the computation still exists:

EitherPath<OrderError, Reservation> reserved =
    EitherPath.withRetry(() -> reserveInventory(order), policy);

// Typically inline, inside a chain:
pipeline.via(order -> EitherPath.withRetry(() -> reserveInventory(order), policy));

Railway-Aware Retry on Typed Carriers

On the typed-error carriers (EitherPath and VResultPath) retry understands the railway. The default overload retries thrown exceptions only: a Left is a business decision, not a fault ("card declined" is an answer, and asking again will not change it), so it is returned as-is and never retried.

Some typed errors are transient, though (a SystemError wrapping a connection reset, say). The typed overload lets a predicate opt those in:

// Instance form on VResultPath
VResultPath<OrderError, Reservation> resilient =
    reserveInventory(order)
        .withRetry(error -> error instanceof OrderError.SystemError, policy);

// Static form on the eager EitherPath
EitherPath<OrderError, Reservation> reserved = EitherPath.withRetry(
    () -> reserveInventory(order),
    error -> error instanceof OrderError.SystemError,
    policy);

The whole loop, drawn once:

flowchart TD
    A["run the attempt"] --> O{"outcome?"}
    O -->|"Right"| RV(["returned:<br/>no retry needed"])
    O -->|"Left: not selected<br/>by the predicate"| LV(["returned as-is:<br/>a business answer"])
    O -->|"exception: not matched<br/>by the policy"| RX(["rethrown<br/>immediately"])
    O -->|"Left: selected, or a<br/>retryable exception"| N{"attempts<br/>left?"}
    N -->|yes| W["wait per the<br/>backoff strategy"] --> A
    N -->|no| X(["exhausted: the last Left,<br/>or RetryExhaustedException"])

    classDef process fill:#8caaee,stroke:#1e66f5,color:#232634
    classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef error fill:#e78284,stroke:#d20f39,color:#232634
    class A,W process
    class O,N decision
    class RV,LV tier
    class X,RX error
Outcome of an attemptBehaviour
RightReturned; no retry
Left, not selected by the predicateReturned immediately; business errors are values
Left, selected by the predicateRetried per the policy; on exhaustion the last Left is returned, keeping the error on the typed channel
Thrown exception matching the policy's predicateRetried; on exhaustion RetryExhaustedException is thrown

Why this matters

An exception-only retry layer cannot see the difference between "the network dropped the request" and "the card was declined": both arrive as failures, and it is on you to encode business outcomes as exceptions and then remember to exclude every one of them from every policy. On the typed carriers that distinction already exists in the type, so the default is right by construction: answers pass through untouched, faults retry, and opting a genuinely transient typed error into retry is one visible predicate at the call site rather than a hidden convention.

Internally both typed carriers are backed by one shared railway-aware retry implementation, so EitherPath and VResultPath behave identically; the only difference is eager-static versus lazy-instance.

Never Retry a Non-Idempotent Step

Retry re-invokes the whole step. Wrapping a step with side effects that must happen at most once (a payment, an email, an inventory commit) risks performing them twice: an attempt can succeed remotely and still throw on the way back. Confine retry to idempotent steps (reads, validations, reservations that can safely be re-issued) and give everything else a single attempt. See Combined Patterns for a worked per-step example.


Handling Exhausted Retries

When all attempts fail, RetryExhaustedException is thrown with the last failure as its cause:

try {
    resilient.run();
} catch (RetryExhaustedException e) {
    log.error("All {} retries failed: {}", e.getAttempts(), e.getMessage());
    Throwable lastFailure = e.getCause();
    // Handle the last failure specifically
}

Monitoring with RetryEvent

The onRetry listener receives a RetryEvent before each retry attempt:

RetryPolicy monitored = RetryPolicy.exponentialBackoff(5, Duration.ofSeconds(1))
    .onRetry(event -> {
        log.warn("Attempt {} failed; retrying in {}: {}",
            event.attemptNumber(),
            event.nextDelay(),
            event.lastException().getMessage());
        metrics.incrementRetryCount(event.attemptNumber());
    });

RetryEvent contains:

FieldTypeDescription
attemptNumber()intThe 1-based attempt that just failed
lastException()ThrowableThe exception that triggered this retry
nextDelay()DurationHow long the system will wait before the next attempt
timestamp()InstantWhen this event occurred

Composing Retry with Other Patterns

Retry composes naturally with other resilience patterns and effect combinators:

VTask<Data> robust = Retry.retryTask(
        VTask.of(() -> primarySource.fetch()),
        RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)))
    .recover(e -> {
        log.warn("Primary exhausted, trying backup", e);
        return Retry.retryTask(
            VTask.of(() -> backupSource.fetch()),
            RetryPolicy.fixed(2, Duration.ofMillis(100))
        ).run();
    })
    .recover(e -> {
        log.error("All sources failed", e);
        return Data.empty();
    });

Quick Reference

PatternCode
Fixed delayRetryPolicy.fixed(3, Duration.ofMillis(100))
Exponential backoffRetryPolicy.exponentialBackoff(5, Duration.ofSeconds(1))
With jitterRetryPolicy.exponentialBackoffWithJitter(5, Duration.ofSeconds(1))
Linear backoffRetryPolicy.linear(5, Duration.ofMillis(200))
Cap max delay.withMaxDelay(Duration.ofSeconds(30))
Retry specific errors.retryOn(IOException.class)
Custom predicate.retryIf(ex -> ...)
Monitor retries.onRetry(event -> ...)
Apply to VTaskRetry.retryTask(task, policy)
Apply to IOPath / VTaskPath / VResultPathpath.withRetry(policy)
Wrap an eager EitherPath stepEitherPath.withRetry(() -> step(), policy)
Railway-aware typed retrypath.withRetry(retryOn, policy) / EitherPath.withRetry(() -> step(), retryOn, policy)
Simple retryRetry.retryTask(task, 3)
Retry with fallbackRetry.retryTaskWithFallback(task, policy, fallbackFn)
Retry with recoveryRetry.retryTaskWithRecovery(task, policy, recoveryFn)

Key Takeaways

  • A policy is a value: immutable RetryPolicy objects describe attempts, backoff, and which failures are worth retrying; jitter prevents synchronised thundering herds
  • Retry wraps a computation: instance-chained withRetry on the lazy carriers, static on the eager EitherPath
  • The railway is respected: a business Left is an answer and is never retried by default; the typed overload opts transient errors in, and exhaustion returns the last Left on the typed channel
  • Only idempotent steps earn retry: an attempt can succeed remotely and still throw on the way back, so keep payments and other one-shot effects out of retry

See Also


Previous: Resilience Patterns Next: Circuit Breaker