Skip to content
tnsaiDocs
GitHub

Resilience

Make an action survive slow or failing dependencies with retries, a timeout and a fallback.

An action gets resilience from three places. @Resilience declares retry and timeout. @Fallback names the method that runs when the action still fails. ResilienceExecutor wraps any operation of your own, outside an action.

What @Resilience applies

@Resilience goes on an action method or on the role class. A method-level annotation replaces the class-level one as a whole. Fields are not merged.

FieldApplied on an actionNotes
retryYesRuns when retry.maxAttempts is above 0.
timeoutYesMilliseconds. 0 means no timeout.
circuitBreakerNoParsed only. Every action gets the default breaker below.
rateLimitNoParsed only.
bulkhead, maxConcurrentNoParsed only.
fallbackNoParsed only. Use @Fallback instead.

If neither retry nor timeout is set, the action runs unwrapped.

When retry or timeout is set, the action also runs behind a circuit breaker keyed by the action name. Its settings are fixed:

  • It opens at a 50% failure rate.
  • It counts the last 20 calls, and waits for at least 10.
  • It stays open for 15 seconds.

Retry an action and fall back

fetchQuote retries up to three times on IOException, with each attempt bounded by a 5 second timeout. If every attempt fails, cachedQuote returns a stand-in value.

package com.example.tnsai.docs;

import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.Fallback;
import com.tnsai.annotations.Resilience;
import com.tnsai.enums.ActionType;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;

import java.io.IOException;

public final class ResilientQuoteRole extends Role {

    @Override
    public RoleIdentity getIdentity() {
        return new RoleIdentity(
                "quotes",
                "Fetch prices from an upstream quote service",
                "market-data");
    }

    @ActionSpec(
            type = ActionType.LOCAL,
            description = "Fetch the latest price for a ticker symbol")
    @Resilience(
            retry = @Resilience.Retry(
                    maxAttempts = 3,
                    backoffMs = 500,
                    retryOn = IOException.class),
            timeout = 5000)
    public String fetchQuote(String symbol) throws IOException {
        return callUpstream(symbol);
    }

    @Fallback(forAction = "fetchQuote")
    public String cachedQuote(String symbol, Exception error) {
        return symbol + ": price unavailable";
    }

    private String callUpstream(String symbol) throws IOException {
        throw new IOException("quote service unreachable");
    }
}

@Resilience.Retry has these fields:

FieldDefaultMeaning
maxAttempts0Total attempts. 0 turns retry off.
backoffMs1000Delay before the second attempt.
multiplier2.0Growth factor for each later delay.
maxBackoffMs30000Upper bound on one delay.
retryOnallException types that trigger a retry. Error types are ignored.
noRetryOnnoneException types that never retry. Error types are ignored.
jittertrueAdds a random 0 to 25% to each delay.

Choose the fallback method

@Fallback marks a method on the same role. The framework calls it after the action and its retries have failed.

  • @Fallback(forAction = "fetchQuote") handles one action.
  • @Fallback with no forAction handles every action on the role that has no fallback of its own.
  • The method takes the action's parameters in declaration order, then an Exception or Throwable.
  • maxRetries reruns the action that many times, with no delay, before the fallback runs. The default is 0.

Set resilience on a programmatic role

A role built with RoleBuilder has no annotated methods. Give it RoleBuilder.resilience(ResilienceConfig) instead:

package com.example.tnsai.docs;

import com.tnsai.resilience.ResilienceConfig;
import com.tnsai.resilience.RetryPolicy;
import com.tnsai.roles.Role;
import com.tnsai.roles.RoleBuilder;

public final class ResilienceRoleBuilderExample {
    private ResilienceRoleBuilderExample() {}

    public static Role quotesRole() {
        return RoleBuilder.create()
                .name("quotes")
                .resilience(ResilienceConfig.builder()
                        .retry(RetryPolicy.builder().maxAttempts(3).build())
                        .timeoutMs(5000)
                        .build())
                .build();
    }
}

The config carries retry and timeout only. It applies when no @Resilience annotation does.

Wrap your own operation

ResilienceExecutor runs any Callable with the full set of layers. Use it for calls you make outside an action.

package com.example.tnsai.docs;

import com.tnsai.resilience.ResilienceExecutor;
import com.tnsai.resilience.ResilienceRequest;
import com.tnsai.resilience.RetryPolicy;
import com.tnsai.resilience.dlq.DeadLetterEntry;
import com.tnsai.resilience.dlq.InMemoryDeadLetterQueue;

import java.time.Duration;

public final class ResilienceExecutorExample {
    private ResilienceExecutorExample() {}

    public static String fetchWithLimits(String symbol) throws Exception {
        InMemoryDeadLetterQueue deadLetters = new InMemoryDeadLetterQueue();
        ResilienceExecutor executor = new ResilienceExecutor(deadLetters);

        ResilienceRequest<String> request = ResilienceRequest.<String>builder()
                .operationId("fetch-quote")
                .operation(() -> callUpstream(symbol))
                .retryPolicy(RetryPolicy.builder()
                        .maxAttempts(3)
                        .initialBackoff(Duration.ofMillis(500))
                        .build())
                .fallbackStrategy(error -> symbol + ": price unavailable")
                .rateLimit(20, 1000)
                .bulkhead(5)
                .timeout(5000)
                .build();

        String result = executor.execute(request);

        for (DeadLetterEntry entry : deadLetters.getEntries("fetch-quote")) {
            System.out.println(entry.getExceptionType() + ": " + entry.getExceptionMessage());
        }
        return result;
    }

    private static String callUpstream(String symbol) {
        throw new IllegalStateException("quote service unreachable");
    }
}

The layers wrap each other from outside in:

  1. Timeout
  2. Rate limit
  3. Bulkhead
  4. Circuit breaker
  5. Retry
  6. The operation

Every failure goes to the dead-letter queue, including a failure that the fallback then recovers. The fallbackStrategy handles ordinary exceptions only. These rejections are thrown to the caller unchanged:

RejectionException
Timeoutcom.tnsai.exceptions.TimeoutException
Circuit breaker openio.github.resilience4j.circuitbreaker.CallNotPermittedException
Bulkhead fullio.github.resilience4j.bulkhead.BulkheadFullException
Rate limit reachedio.github.resilience4j.ratelimiter.RequestNotPermitted

Each DeadLetterEntry has getOperationId(), getExceptionType(), getExceptionMessage(), getTimestamp() and getSpec(). getSpec() returns the request metadata plus operationId and failureType.