Every system that calls another system eventually needs resilience: retry the call when it fails, give up if it takes too long, and wait a growing interval between attempts so you do not hammer a struggling dependency. Retry, timeout, backoff.

These usually show up in one of two forms. The first is hand-rolled imperative code — a for loop with a try/catch, a Thread.sleep, an attempt counter, and a future for the timeout, all interleaved with the actual business call until you cannot tell the policy from the work. The second is a full resilience framework, pulled in as a dependency, configured with annotations and a registry, doing far more than the one call needed.

There is a lighter option that sits between them, and it is built from a single idea you already know: a higher-order function that takes a function and returns a function. When retry, timeout, and backoff are each just a function that wraps a function, they compose. You stack them like layers, and the composed result is itself an ordinary function you can call, pass around, and test.

This post builds that toolkit from scratch.


The Shared Shape

The whole approach rests on agreeing what “the work” looks like. A call that can fail is, in functional terms, a Supplier of a Result:

// A unit of work: produces a value or a typed failure, no exceptions thrown
@FunctionalInterface
interface Operation<T> extends Supplier<Result<T, Failure>> {}

Using Result<T, Failure> instead of letting the operation throw is the key decision. It means “failure” is a value the wrappers can inspect and react to — not control flow that escapes the lambda. (If you are wrapping a method that throws, Try.of(...) converts the exception into a Result at the boundary; from there everything is uniform.)

A resilience policy, then, is a function from one Operation to another:

// A policy decorates an operation, producing a new operation with extra behavior
@FunctionalInterface
interface Policy {
<T> Operation<T> wrap(Operation<T> op);
}

Every policy below implements this one interface. Because the output of wrap is the same shape as its input, policies chain.

Our running example is a flaky remote lookup:

Operation<Price> fetchPrice = () ->
Try.of(() -> priceClient.get(sku)) // may throw
.toResult()
.mapError(Failure::fromException); // normalize to our Failure type

Policy 1: Retry

Retry is the simplest policy to state: run the operation; if it fails, run it again, up to a maximum number of attempts.

static Policy retry(int maxAttempts) {
return new Policy() {
@Override
public <T> Operation<T> wrap(Operation<T> op) {
return () -> {
Result<T, Failure> last = op.get();
for (int attempt = 2; attempt <= maxAttempts && last.isErr(); attempt++) {
last = op.get();
}
return last;
};
}
};
}

Notice what this does not do: sleep, impose a timeout, or decide which failures are retryable. It does one thing — repeat on failure — and it returns a new Operation that you can wrap further. That single responsibility is what makes it composable.

A real retry should not blindly retry everything, though. A 404 Not Found will be a 404 on every attempt; retrying it just wastes time. So the next refinement takes a predicate that decides whether a given failure is retryable:

static Policy retry(int maxAttempts, Predicate<Failure> retryable) {
return new Policy() {
@Override
public <T> Operation<T> wrap(Operation<T> op) {
return () -> {
Result<T, Failure> last = op.get();
int attempt = 1;
while (attempt < maxAttempts
&& last instanceof Result.Err<T, Failure> e
&& retryable.test(e.error())) {
last = op.get();
attempt++;
}
return last;
};
}
};
}

The retryable predicate is itself a composable value — exactly the kind of small, reusable function worth building a library of: Failure::isTransient, f -> f.statusCode() >= 500, and so on.


Policy 2: Backoff

Retrying immediately in a tight loop is often worse than not retrying at all — you turn one struggling dependency into a thundering herd. Backoff inserts a growing delay between attempts.

The delay schedule is, naturally, a function: given the attempt number, return how long to wait.

// A backoff strategy is just attempt number -> delay
@FunctionalInterface
interface Backoff {
Duration delayBefore(int attempt);
static Backoff none() { return a -> Duration.ZERO; }
static Backoff fixed(Duration d) { return a -> d; }
static Backoff linear(Duration step) { return a -> step.multipliedBy(a); }
// Exponential: base, 2·base, 4·base, 8·base ...
static Backoff exponential(Duration base) {
return a -> base.multipliedBy(1L << (a - 1));
}
}

Because the schedule is a plain function, you can decorate it the same way you decorate operations. Adding jitter — a small random offset that de-synchronizes many clients retrying at once — is a wrapper around a Backoff:

static Backoff withJitter(Backoff base, double ratio) {
return attempt -> {
Duration d = base.delayBefore(attempt);
long jitter = (long) (d.toMillis() * ratio * ThreadLocalRandom.current().nextDouble());
return d.plusMillis(jitter);
};
}

Rather than make retry and backoff separate layers that have to coordinate attempt counts, it is cleaner to let the retry policy accept a Backoff and sleep between its own attempts:

static Policy retry(int maxAttempts, Predicate<Failure> retryable, Backoff backoff) {
return new Policy() {
@Override
public <T> Operation<T> wrap(Operation<T> op) {
return () -> {
Result<T, Failure> last = op.get();
int attempt = 1;
while (attempt < maxAttempts
&& last instanceof Result.Err<T, Failure> e
&& retryable.test(e.error())) {
sleep(backoff.delayBefore(attempt)); // wait, growing each time
last = op.get();
attempt++;
}
return last;
};
}
};
}

The retry policy owns how many times; the Backoff owns how long between. Two small functions, each independently testable, each replaceable without touching the other.


Policy 3: Timeout

Timeout is the one policy that genuinely needs concurrency: to abandon a call that runs too long, you must run it somewhere you can stop waiting on. The shape, however, stays identical — it is still Operation<T> → Operation<T>.

static Policy timeout(Duration limit, ExecutorService executor) {
return new Policy() {
@Override
public <T> Operation<T> wrap(Operation<T> op) {
return () -> {
Future<Result<T, Failure>> future = executor.submit(op::get);
try {
return future.get(limit.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
return Result.err(Failure.timedOut(limit));
} catch (ExecutionException | InterruptedException e) {
future.cancel(true);
return Result.err(Failure.fromException(e));
}
};
}
};
}

A timeout that fires becomes a Failure.timedOut — an ordinary value on the error channel. That matters for composition: when timeout is wrapped inside retry, a timed-out attempt is just another failure the retry loop can inspect with its retryable predicate and decide to try again.


Composition: Stacking the Policies

Here is where the one-shape decision pays off. Each policy is Operation → Operation, so composing them is function composition. Order is the whole design:

static Policy andThen(Policy outer, Policy inner) {
return new Policy() {
@Override
public <T> Operation<T> wrap(Operation<T> op) {
return outer.wrap(inner.wrap(op)); // inner runs closest to the work
}
};
}

Now express a complete resilience strategy as a single readable value:

Policy resilient = andThen(
retry(4, Failure::isTransient, withJitter(Backoff.exponential(ofMillis(100)), 0.5)),
timeout(ofSeconds(2), executor)
);
Operation<Price> guarded = resilient.wrap(fetchPrice);
Result<Price, Failure> price = guarded.get();

Read that top to bottom: each individual attempt is bounded to 2 seconds; the whole thing is retried up to 4 times for transient failures, waiting an exponential, jittered interval between tries. The wrapping order encodes the meaning:

  • timeout inside retry → each attempt gets its own fresh 2-second budget; a slow attempt is retried.
  • timeout outside retry → the 2 seconds is a budget for all attempts combined; once it elapses, no more retries.

Both are legitimate; they are different requirements. With imperative code you would rewrite the loop to switch between them. With composable policies you swap which wraps which — the policies themselves do not change.


Why This Beats the Imperative Loop

The hand-rolled version tangles four concerns — the call, the attempt counting, the sleeping, the timeout future — into one block of code. To change the backoff curve you edit the loop. To test the retry logic you must also stand up the timeout machinery. The policy and the work are fused.

The composable version keeps each concern in its own function:

  • Each policy is independently testable. Test retry with an in-memory operation that fails twice then succeeds, and assert it was called three times. No clock, no network, no executor.
  • The backoff schedule is a pure function. assertThat(exponential(ofMillis(100)).delayBefore(3)).isEqualTo(ofMillis(400)) — a one-line test, no timing involved.
  • Policies are reusable across call sites. Define standardRemotePolicy once; wrap every outbound call with it. Consistency by construction, not by copy-paste.
  • The behavior is readable in one place. The composed Policy value is the documentation of how that call behaves under failure.

When to Reach for the Framework Instead

This toolkit is deliberately small, and there is a real boundary where a dedicated resilience library earns its weight. Reach for one when you need:

  • Circuit breaking — tracking failure rates across many calls over time to stop calling a dead dependency entirely. That requires shared, stateful coordination this stateless-function model does not provide.
  • Bulkheads and rate limiting — bounding concurrency across the whole application, again shared state.
  • Metrics and event streams — production-grade observability of every retry and timeout.

The composable-function approach shines for the common 80%: a handful of outbound calls that need sensible retry, timeout, and backoff without a new dependency and its configuration surface. When you outgrow it, the call sites barely change — you are still wrapping an Operation, just with a policy backed by a library.


Summary

Resilience does not have to be either a tangled loop or a heavyweight framework. Model the work as an Operation<T> that returns Result, model each policy as Operation → Operation, and the three classic patterns fall out as small functions:

  • Retry repeats on failure, guided by a Predicate<Failure> for which failures.
  • Backoff is an attempt -> Duration schedule, decoratable with jitter.
  • Timeout runs an attempt where it can be abandoned, turning “too slow” into an ordinary Failure.

Because every policy shares one shape, they compose by ordinary function composition — and the order in which you stack them is the resilience strategy, written in one readable line.


Further reading