Resilience4j with Custom Context Propagators in Spring Boot 3
Table of Contents
Resilience4j’s async decorators — TimeLimiter, Bulkhead — run your code on a different thread. That thread does not automatically have your MDC fields, tenant identifier, or other thread-local state. The result: a log line written inside a resilience decorator has no correlation ID, and a tenant-scoped database call fails because the scope is absent.
Resilience4j solves this with ContextPropagator: a three-method interface you implement to define what gets captured, copied, and cleared around thread boundaries. This post covers the implementation and wiring.
Dependencies #
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-web:3.4.3'
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.3.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.3.0'
implementation 'io.github.resilience4j:resilience4j-timelimiter:2.3.0'
implementation 'io.github.resilience4j:resilience4j-bulkhead:2.3.0'
Java 21. Resilience4j 2.3.0 requires Spring Boot 3.x.
The ContextPropagator Interface #
public interface ContextPropagator<T> {
Supplier<Optional<T>> retrieve();
UnaryOperator<Optional<T>> copy();
Consumer<Optional<T>> clear();
}
The three methods map to three points in the task lifecycle:
retrieve()— called on the submitting thread. Returns aSupplierthat captures the current context.copy()— called on the worker thread before the task runs. Restores context from whatretrieve()captured.clear()— called on the worker thread after the task finishes. Removes the context.
Implementing EntityScope Propagation #
For a tenant-scoped thread-local:
@Component
public class EntityScopeContextPropagator implements ContextPropagator<String> {
@Override
public Supplier<Optional<String>> retrieve() {
// Capture current tenant ID on the submitting thread
String tenantId = EntityScope.get();
return () -> Optional.ofNullable(tenantId);
}
@Override
public UnaryOperator<Optional<String>> copy() {
return tenantIdOptional -> {
// Restore tenant ID on the worker thread
tenantIdOptional.ifPresent(EntityScope::set);
return tenantIdOptional;
};
}
@Override
public Consumer<Optional<String>> clear() {
// Clean up after task completes
return ignored -> EntityScope.clear();
}
}
Implementing MDC Propagation #
@Component
public class MdcContextPropagator implements ContextPropagator<Map<String, String>> {
@Override
public Supplier<Optional<Map<String, String>>> retrieve() {
Map<String, String> mdcCopy = MDC.getCopyOfContextMap();
return () -> Optional.ofNullable(mdcCopy);
}
@Override
public UnaryOperator<Optional<Map<String, String>>> copy() {
return mdcOptional -> {
mdcOptional.ifPresent(MDC::setContextMap);
return mdcOptional;
};
}
@Override
public Consumer<Optional<Map<String, String>>> clear() {
return ignored -> MDC.clear();
}
}
Wiring Propagators in application.yml #
Resilience4j reads context-propagators as a list of class names per instance. You can configure different propagators per backend:
resilience4j:
circuitbreaker:
instances:
external-config-api:
sliding-window-type: TIME_BASED
sliding-window-size: 5
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
context-propagators:
- com.example.service.resilience.EntityScopeContextPropagator
- com.example.service.resilience.MdcContextPropagator
payment-api:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
failure-rate-threshold: 60
wait-duration-in-open-state: 30s
context-propagators:
- com.example.service.resilience.EntityScopeContextPropagator
- com.example.service.resilience.MdcContextPropagator
timelimiter:
instances:
external-config-api:
timeout-duration: 5s
cancel-running-future: true
payment-api:
timeout-duration: 10s
bulkhead:
instances:
external-config-api:
max-concurrent-calls: 20
max-wait-duration: 500ms
payment-api:
max-concurrent-calls: 10
max-wait-duration: 1s
Using the Decorators #
@Service
public class ExternalConfigService {
private final CircuitBreaker circuitBreaker;
private final TimeLimiter timeLimiter;
private final ScheduledExecutorService scheduler;
private final RestClient configClient;
public ExternalConfigService(CircuitBreakerRegistry cbRegistry,
TimeLimiterRegistry tlRegistry) {
this.circuitBreaker = cbRegistry.circuitBreaker("external-config-api");
this.timeLimiter = tlRegistry.timeLimiter("external-config-api");
this.scheduler = Executors.newScheduledThreadPool(5);
}
public ConfigResponse fetchConfig(String entityCode) {
Supplier<CompletableFuture<ConfigResponse>> futureSupplier = () ->
CompletableFuture.supplyAsync(() -> configClient.get()
.uri("/config/{entityCode}", entityCode)
.retrieve()
.body(ConfigResponse.class), scheduler);
try {
return timeLimiter.executeFutureSupplier(
circuitBreaker.decorateSupplier(futureSupplier));
} catch (TimeoutException e) {
log.warn("Config fetch timed out for {}", entityCode);
return ConfigResponse.empty();
} catch (CallNotPermittedException e) {
log.warn("Circuit breaker open for external-config-api");
return ConfigResponse.empty();
} catch (Exception e) {
log.error("Config fetch failed for {}", entityCode, e);
return ConfigResponse.empty();
}
}
}
When CompletableFuture.supplyAsync() dispatches to the scheduler thread, the EntityScopeContextPropagator and MdcContextPropagator will have already set the context. The supplier runs with the full context of the calling thread.
Annotation-Based Alternative #
If you prefer the annotation style over programmatic decoration:
@Service
public class ExternalConfigService {
private final RestClient configClient;
@CircuitBreaker(name = "external-config-api", fallbackMethod = "fetchConfigFallback")
@TimeLimiter(name = "external-config-api")
@Bulkhead(name = "external-config-api")
public CompletableFuture<ConfigResponse> fetchConfig(String entityCode) {
return CompletableFuture.supplyAsync(() -> configClient.get()
.uri("/config/{entityCode}", entityCode)
.retrieve()
.body(ConfigResponse.class));
}
public CompletableFuture<ConfigResponse> fetchConfigFallback(
String entityCode, Exception e) {
log.warn("Falling back to empty config for {}: {}", entityCode, e.getMessage());
return CompletableFuture.completedFuture(ConfigResponse.empty());
}
}
The annotations use the same configuration from application.yml, including the context-propagators list. Context propagation happens transparently.
Circuit Breaker Behaviour #
The TIME_BASED sliding window counts failures within a time window rather than the last N calls. This is more appropriate for latency-sensitive dependencies: if a downstream service slows down and starts timing out in bursts, a time-based window opens the circuit faster than a count-based window of the same size.
The configuration above (sliding-window-size: 5, failure-rate-threshold: 50) means: if more than 50% of calls in the last 5 seconds failed, open the circuit. wait-duration-in-open-state: 10s is how long to stay open before allowing a probe call through (HALF_OPEN). permitted-number-of-calls-in-half-open-state: 3 means allow 3 probe calls — if 2 or more succeed, close the circuit.
What the Propagators Don’t Cover #
ContextPropagator handles thread boundaries within Resilience4j’s async execution. It does not propagate context to:
- Threads spawned inside the task itself
@AsyncSpring methods called from within the decorated code- Reactive chains (Reactor/WebFlux)
For those cases you need the executor service wrapper pattern or Reactor’s context propagation mechanisms. The Resilience4j propagators are specifically for the thread hop that the framework itself introduces.