Fault Tolerance and Resilience

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

quarkus-smallrye-fault-tolerance implements the MicroProfile Fault Tolerance specification and adds several SmallRye-specific extensions to it (@RateLimit, programmatic Guard`s). Every annotation on this page is a CDI interceptor binding, so it applies to any CDI bean method exactly the same way whether that method backs a REST resource, a REST client interface (already shown stacking `@Retry and @CircuitBreaker on a client method), a messaging consumer, or a plain application service — nothing on this page is specific to any one of those call sites. For the Spring Boot counterpart, see Resilience Fundamentals.

./mvnw quarkus:add-extension -Dextensions=smallrye-fault-tolerance

@Retry

package com.example.orders;

import java.time.temporal.ChronoUnit;

import org.eclipse.microprofile.faulttolerance.Retry;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class InventoryClient {

    @Retry(
            maxRetries = 4,
            delay = 200, delayUnit = ChronoUnit.MILLIS,
            jitter = 100, jitterDelayUnit = ChronoUnit.MILLIS,
            maxDuration = 5, durationUnit = ChronoUnit.SECONDS,
            retryOn = { java.io.IOException.class },
            abortOn = { IllegalArgumentException.class })
    public InventoryLevel checkLevel(String sku) {
        return fetchFromInventoryService(sku);
    }

    private InventoryLevel fetchFromInventoryService(String sku) {
        // remote call
        return null;
    }
}

delay/jitter together produce a randomised back-off in [delay - jitter, delay + jitter] for each attempt — jitter exists specifically to stop many callers retrying a recovering downstream service in lockstep and re-overwhelming it the instant it comes back. maxDuration bounds total time spent retrying regardless of maxRetries, so a slow failure mode cannot keep retrying far longer than the caller can tolerate. retryOn narrows retries to specific exception types (anything else propagates immediately); abortOn is the inverse — exceptions that should never be retried even if they match `retryOn’s type hierarchy, typically because they indicate the request itself was wrong rather than that the call failed transiently.

@Timeout

import java.time.temporal.ChronoUnit;

import org.eclipse.microprofile.faulttolerance.Timeout;

@Timeout(value = 800, unit = ChronoUnit.MILLIS)
public InventoryLevel checkLevel(String sku) {
    return fetchFromInventoryService(sku);
}

A method that has not returned within the given duration is interrupted and a org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException is raised in its place — this composes with @Retry (a timeout counts as a retryable failure by default) and is usually the annotation that actually bounds an otherwise-unbounded blocking call, since neither @Retry nor @CircuitBreaker alone limits how long a single attempt may run.

@Bulkhead

import org.eclipse.microprofile.faulttolerance.Bulkhead;
import org.eclipse.microprofile.faulttolerance.Asynchronous;

// semaphore bulkhead: bounds concurrent synchronous callers
@Bulkhead(5)
public InventoryLevel checkLevel(String sku) {
    return fetchFromInventoryService(sku);
}

// thread-pool bulkhead: bounds concurrent async executions, with a bounded waiting queue
@Asynchronous
@Bulkhead(value = 5, waitingTaskQueue = 20)
public java.util.concurrent.CompletionStage<InventoryLevel> checkLevelAsync(String sku) {
    return java.util.concurrent.CompletableFuture.completedFuture(fetchFromInventoryService(sku));
}

The semaphore form (the default, for a plain synchronous method) limits how many callers may be executing the method at once — the (6+)`th concurrent caller is rejected immediately with a `BulkheadException rather than queued. The thread-pool form applies only to @Asynchronous methods: it runs on a dedicated bounded thread pool with its own bounded waitingTaskQueue, so excess work queues briefly instead of being rejected outright, at the cost of an extra thread-pool hand-off. Either way, a bulkhead’s purpose is to stop one overloaded or misbehaving downstream dependency from consuming every thread/permit in the caller and starving unrelated calls that share the same process.

@CircuitBreaker

import java.time.temporal.ChronoUnit;

import org.eclipse.microprofile.faulttolerance.CircuitBreaker;

@CircuitBreaker(
        requestVolumeThreshold = 10,
        failureRatio = 0.5,
        delay = 5, delayUnit = ChronoUnit.SECONDS,
        successThreshold = 3)
public InventoryLevel checkLevel(String sku) {
    return fetchFromInventoryService(sku);
}

requestVolumeThreshold is the minimum number of calls, within a rolling window of that same size, before the circuit can trip at all — it exists so that a handful of early failures right after startup cannot open the circuit on statistically meaningless data. Once at least that many calls have been made, failureRatio is compared against the fraction of them that failed; crossing it opens the circuit. delay is how long the circuit stays open before allowing a single probe call through (transitioning to half-open); successThreshold is how many consecutive successful probe calls are needed, while half-open, before the circuit closes again — a single failed probe reopens it immediately.

The state machine

stateDiagram-v2 [*] --> Closed Closed --> Closed: call succeeds, or failure ratio below threshold Closed --> Open: failureRatio exceeded once requestVolumeThreshold calls observed Open --> HalfOpen: delay elapses Open --> Open: call rejected immediately (CircuitBreakerOpenException) HalfOpen --> Closed: successThreshold consecutive probe calls succeed HalfOpen --> Open: any probe call fails

While open, every call fails fast with a CircuitBreakerOpenException without ever invoking the method body — this is the actual point of a circuit breaker: giving a struggling downstream dependency a real window with zero additional load, instead of every caller still hammering it while individually timing out.

Programmatic control: CircuitBreakerMaintenance

package com.example.orders;

import io.smallrye.faulttolerance.api.CircuitBreakerMaintenance;
import io.smallrye.faulttolerance.api.CircuitBreakerState;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class CircuitBreakerAdminService {

    @Inject
    CircuitBreakerMaintenance maintenance;

    public CircuitBreakerState currentState() {
        return maintenance.currentState("checkLevel-circuit");
    }

    public void forceReset() {
        maintenance.resetAll();
    }
}

io.smallrye.faulttolerance.api.CircuitBreakerMaintenance (a named circuit breaker requires @CircuitBreakerName("checkLevel-circuit") alongside @CircuitBreaker to be addressable this way) lets an operator inspect or forcibly reset circuit state from application code — an admin endpoint, a Quarkus command, or a Dev UI extension — rather than waiting out delay or restarting the instance.

@Fallback

import org.eclipse.microprofile.faulttolerance.Fallback;

@Retry(maxRetries = 2)
@CircuitBreaker
@Fallback(fallbackMethod = "cachedLevel")
public InventoryLevel checkLevel(String sku) {
    return fetchFromInventoryService(sku);
}

InventoryLevel cachedLevel(String sku) {
    return InventoryLevel.unknown(sku);
}

fallbackMethod must match the original method’s signature exactly (same parameter types, same or a covariant return type) and runs once every other configured strategy (retries exhausted, circuit open, timeout) has given up — @Fallback is deliberately the outermost annotation in the execution order (see Composing several strategies on one call), since a fallback should only ever run after every recovery attempt has already failed.

@RateLimit

import java.time.temporal.ChronoUnit;

import io.smallrye.faulttolerance.api.RateLimit;

@RateLimit(value = 100, window = 1, windowUnit = ChronoUnit.SECONDS, minSpacing = 5, minSpacingUnit = ChronoUnit.MILLIS)
public InventoryLevel checkLevel(String sku) {
    return fetchFromInventoryService(sku);
}

io.smallrye.faulttolerance.api.RateLimit is a SmallRye extension beyond the MicroProfile Fault Tolerance specification: it caps a method to at most value invocations per window, rejecting any call beyond that with a RateLimitException, and minSpacing additionally enforces a minimum gap between any two consecutive calls regardless of the window count. Unlike @Bulkhead (which limits concurrent callers), @RateLimit limits throughput over time — the two compose naturally when a downstream dependency has both a concurrency limit and a calls-per-second quota.

@ApplyGuard and programmatic guards

package com.example.orders;

import java.time.Duration;

import io.smallrye.faulttolerance.api.ApplyGuard;
import io.smallrye.faulttolerance.api.FaultTolerance;
import io.smallrye.faulttolerance.api.Guard;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;

@ApplicationScoped
public class InventoryGuards {

    @Produces
    @ApplicationScoped
    public Guard inventoryGuard() {
        return Guard.create()
                .withRetry().maxRetries(3).delay(Duration.ofMillis(200)).done()
                .withTimeout().duration(Duration.ofMillis(800)).done()
                .withCircuitBreaker().requestVolumeThreshold(10).failureRatio(0.5).done()
                .build();
    }

    @ApplyGuard("inventoryGuard")
    public InventoryLevel checkLevel(String sku) {
        return fetchFromInventoryService(sku);
    }
}

io.smallrye.faulttolerance.api.Guard/FaultTolerance builds the same strategies programmatically instead of through annotations — useful when the configuration itself needs to be computed or shared identically across several methods without repeating annotation attributes on each one. @ApplyGuard("inventoryGuard") attaches a named, CDI-produced Guard bean to a method, combining the ergonomics of the annotation-based interceptor with programmatic construction.

Asynchronous and Mutiny support

Every annotation on this page applies to a method returning java.util.concurrent.CompletionStage/Future (marked @Asynchronous) exactly as it does to a synchronous one, and quarkus-smallrye-fault-tolerance extends this natively to Mutiny’s reactive types:

import io.smallrye.mutiny.Uni;
import io.smallrye.faulttolerance.api.RateLimit;

@Retry(maxRetries = 3)
@CircuitBreaker
@Timeout(500)
public Uni<InventoryLevel> checkLevelReactive(String sku) {
    return fetchFromInventoryServiceReactive(sku);
}

A method returning Uni<T> needs no @Asynchronous annotation and no adapter — SmallRye Fault Tolerance recognises the reactive return type and applies retry/timeout/circuit-breaker/bulkhead semantics to the subscription rather than to a blocking call, which is what makes it safe to stack directly on reactive data access (see Reactive Data Access) and reactive REST client calls without introducing blocking behaviour.

Metrics integration

With quarkus-micrometer (or the OpenTelemetry bridge, see Observability) also present, every fault-tolerance strategy emits its own meters without extra code — retry attempt counts, circuit-breaker state transitions and time spent in each state, bulkhead rejections, and timeout occurrences, each tagged with the method they apply to. This is what makes it practical to notice, say, a circuit breaker flapping between closed and open in production instead of only inferring it from elevated error rates:

quarkus.fault-tolerance.metrics.enabled=true

Disabling fault tolerance

Fault tolerance can be turned off globally, which is occasionally useful for isolating whether an observed failure is caused by application logic or by a fault-tolerance strategy itself:

quarkus.fault-tolerance.enabled=false

Per the MicroProfile Fault Tolerance specification, every non-fallback strategy (retry, timeout, bulkhead, circuit breaker) can instead be disabled while keeping fallbacks active, via the standard MP_Fault_Tolerance_NonFallback_Enabled=false configuration property — useful for a test profile that wants a fallback’s behaviour exercised without the delay a real retry/timeout would add. A single annotation on a single method can also be disabled individually using the specification’s <fully-qualified-class-name>/<method-name>/<AnnotationType>/enabled=false configuration key pattern, without touching the annotation itself in code.

Load shedding

Retries, bulkheads, and circuit breakers all protect a caller from a struggling downstream dependency; load shedding protects an application itself from being overwhelmed by more concurrent work than it can process, by proactively rejecting a portion of incoming requests once the system is past a healthy operating point rather than queueing them and letting latency balloon for everyone, including requests that arrive after the overload has already passed. This is a property of the HTTP layer serving inbound requests rather than of any one CDI-intercepted method, and the exact configuration surface is intentionally not duplicated here — see the dedicated load-shedding reference guide linked below for the current configuration keys and the algorithm behind them.

Service discovery and load balancing with SmallRye Stork

./mvnw quarkus:add-extension -Dextensions=smallrye-stork

Stork resolves a logical service name to a concrete instance list on the consumer side, so a client never hardcodes a downstream URL — it was already introduced briefly on the REST client page via the stork:// URL scheme; this section covers the mechanism itself.

Service-discovery providers

# static list, for local development or a fixed set of instances
quarkus.stork.orders-service.service-discovery.type=static
quarkus.stork.orders-service.service-discovery.address-list=orders-1:8080,orders-2:8080

# Kubernetes: resolves instances from a Service/Endpoints object
quarkus.stork.orders-service.service-discovery.type=kubernetes
quarkus.stork.orders-service.service-discovery.k8s-namespace=orders

# Consul: resolves instances registered in Consul's service catalog
quarkus.stork.orders-service.service-discovery.type=consul
quarkus.stork.orders-service.service-discovery.consul-host=consul.example.com
quarkus.stork.orders-service.service-discovery.consul-port=8500

Stork itself only discovers instances — it does not register them. Registration is the responsibility of the platform each provider queries: a Kubernetes Service is populated by kubelet health-checked Pod readiness, and a Consul entry is registered by a Consul agent (or the application via Consul’s own registration API) running alongside the instance, independently of Stork.

Load-balancer strategies

quarkus.stork.orders-service.load-balancer.type=round-robin
# or: random, least-response-time

Round-robin cycles evenly through resolved instances; random picks one uniformly at random per call (useful when many independent clients each doing round-robin could otherwise synchronise on the same instance); least-response-time tracks recent latency per instance and biases traffic toward the currently-fastest one, at the cost of more per-call bookkeeping than the other two strategies.

Integration with the REST client and gRPC

A stork://<service-name>/…​ URL is recognised anywhere a target host is configured — the REST client (quarkus.rest-client."orders-api".url=stork://orders-service/orders, as shown on the REST client page) and a gRPC client (quarkus.grpc.clients.orders.host=stork://orders-service, see gRPC) both resolve through the exact same Stork configuration — the discovery and load-balancing configuration is entirely independent of which protocol ultimately uses it.

Composing several strategies on one call

flowchart TB A["Caller invokes the method"] --> B["@Fallback\n(outermost: catches any\nfailure below it)"] B --> C["@Retry\n(re-invokes everything below\non a retryable failure)"] C --> D["@CircuitBreaker\n(fails fast without\ninvoking below, if open)"] D --> E["@Timeout\n(bounds how long the\ncall below may run)"] E --> F["@Bulkhead\n(bounds concurrent\nexecutions of the method)"] F --> G["Actual method body"] G -.->|"success"| B G -.->|"failure"| C D -.->|"circuit open:\nCircuitBreakerOpenException"| B C -.->|"retries exhausted"| B

The MicroProfile Fault Tolerance specification fixes this order deliberately: @Fallback sits outermost so it can catch a failure from any inner strategy, including a circuit that is currently open; @Retry wraps @CircuitBreaker so a retried call can still be short-circuited without re-attempting a downstream call known to be failing; @Timeout wraps @Bulkhead so a call already holding a bulkhead permit can still be aborted for taking too long. A single failed attempt can therefore be, in order: rejected by the bulkhead, timed out, counted toward the circuit breaker’s failure ratio, retried, and finally — only once retries are exhausted or the circuit is open — resolved by the fallback.