← Home
Resilience Patterns

Top 8 Senior Interview Questions

Circuit Breaker · Retry · Bulkhead · Timeout · Rate Limiting · Fallback · Resilience4j

Questions

  1. Circuit breaker — state machine, implementation, and observability
  2. Retry with exponential backoff and jitter — why naive retry fails
  3. Bulkhead — thread-pool isolation to prevent cascade failure
  4. Timeout and deadline propagation across service hops
  5. Rate limiting — algorithms and distributed rate limiting
  6. Fallback strategies — when they help and when they hurt
  7. Resilience4j + Spring Boot — wiring, ordering, and observability
  8. Designing for failure end-to-end — how you respond when a dependency goes down
Q1 · Circuit Breaker
Explain the circuit breaker pattern. Walk me through the state machine and when you'd use it.

Direct answer

The circuit breaker prevents your service from repeatedly hammering a failing downstream service. Without it, every request waits for a full timeout before failing, threads pile up, and the slowness cascades. With a circuit breaker, once failure rate crosses a threshold the circuit opens — subsequent calls fail immediately without touching the downstream service, giving it time to recover.

The three-state machine

CLOSED — normal operation. Every call goes through. A sliding window tracks the failure rate. When the failure rate exceeds the threshold (e.g. 50% over the last 10 calls), the circuit transitions to OPEN.

OPEN — fast-fail mode. All calls immediately throw CallNotPermittedException without touching the downstream. After a configured wait duration (e.g. 30s), the circuit transitions to HALF_OPEN.

HALF_OPEN — probe mode. A limited number of calls are let through as test probes. If they succeed above the success rate threshold, the circuit closes. If they fail, it reopens and the wait duration resets.

// Resilience4j configuration
@Bean
public CircuitBreakerConfig paymentCbConfig() {
    return CircuitBreakerConfig.custom()
        .slidingWindowType(SlidingWindowType.COUNT_BASED)
        .slidingWindowSize(10)                    // last 10 calls
        .failureRateThreshold(50)                // open at 50% failures
        .minimumNumberOfCalls(5)                 // don't open on first failure
        .waitDurationInOpenState(Duration.ofSeconds(30))
        .permittedNumberOfCallsInHalfOpenState(3)
        .slowCallRateThreshold(80)               // also open on slow calls
        .slowCallDurationThreshold(Duration.ofSeconds(2))
        .build();
}

// Usage with annotation
@CircuitBreaker(name = "payment", fallbackMethod = "paymentFallback")
public PaymentResult charge(ChargeRequest req) {
    return paymentClient.charge(req);
}

private PaymentResult paymentFallback(ChargeRequest req, Throwable t) {
    log.warn("Payment CB open, falling back", t);
    return PaymentResult.pending(req.getOrderId());  // queue for async retry
}

Count-based vs time-based sliding window

Count-based: tracks the last N calls. Simple but can lag — if traffic drops to 1 call/minute, "last 10 calls" might span 10 minutes and miss a recent burst of failures. Time-based: tracks calls in the last N seconds. Reacts to recent failures regardless of traffic volume. I use time-based for high-traffic services.

The production gotcha

Setting minimumNumberOfCalls too low — or not setting it — causes the circuit to open on the very first failure after a restart. Your service just deployed, the downstream hasn't responded to the first probe yet, and the circuit opens immediately. Always set a minimum of 5–10 calls before the failure rate calculation kicks in.

Also: circuit breaker state transitions should be alerted on, not just logged. A circuit opening in production is a signal that something is wrong downstream. Wire state transition events to your alerting system:

registry.circuitBreaker("payment")
    .getEventPublisher()
    .onStateTransition(e -> metrics.recordCbTransition(e.getStateTransition()))
    .onCallNotPermitted(e -> metrics.incrementCbRejected());
Interview drill-down — expect these follow-ups:
L1 → "HALF_OPEN lets 3 probe calls through. What if one probe succeeds and two fail?"
The circuit reopens — permittedNumberOfCallsInHalfOpenState calls are evaluated as a group against the failureRateThreshold. So 1 success + 2 failures = 66% failure rate. If your threshold is 50%, the circuit reopens and the wait duration resets. The service needs a clean recovery window, not just one lucky success.
L2 → "Slow calls — you configured slowCallRateThreshold. Why should slow calls open the circuit?"
A service that takes 10s to respond is almost as bad as one that fails — it holds threads, exhausts connection pools, and degrades user experience. The circuit breaker should protect your threads from being held hostage by a slow downstream regardless of whether calls technically "succeed". Without slowCallRateThreshold, a service that responds in 8s for every request would never trip the circuit despite causing serious degradation.
L3 → "How does the circuit breaker behave in a reactive (WebFlux) stack?"
Resilience4j has a separate resilience4j-reactor module. You apply the circuit breaker via operators rather than annotations: Mono.fromCallable(() -> ...).transformDeferred(CircuitBreakerOperator.of(cb)). The annotation-based approach still works in WebFlux but returns CompletableFuture or Mono wrappers. Key difference: in reactive, "slow call" is measured from subscription to termination of the Mono/Flux — not wall-clock thread hold time.
Trap answer to avoid: "I set failureRateThreshold to 100% so it only opens when everything fails." — This defeats the purpose. At 100% failure rate you're in a full outage already. A proper threshold (50%) opens the circuit early enough to protect resources during partial failures, before complete service breakdown.
Q2 · Retry
How should you implement retry in a distributed system? What goes wrong with naive retry?

Direct answer

Naive retry — retry immediately on failure, N times — makes things worse. When a service is struggling, all failing clients retrying immediately flood it with the same load that caused the failure. The right approach is exponential backoff with jitter: each attempt waits longer than the last, and randomisation ensures clients don't all retry at the same instant.

Exponential backoff + jitter

Exponential backoff: wait = base × multiplier^attempt (e.g. 100ms, 200ms, 400ms, 800ms). Without jitter, all retrying clients still synchronise on those exact intervals — a wave at 100ms, a wave at 200ms, etc. Jitter randomises the wait within a range, spreading load across time.

# application.yml — correct retry config
resilience4j:
  retry:
    instances:
      payment:
        maxAttempts: 4
        waitDuration: 100ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        enableRandomizedWait: true
        randomizedWaitFactor: 0.5   # ±50% jitter → 50–150ms, 100–300ms, 200–600ms
        retryExceptions:
          - java.net.ConnectException
          - java.net.SocketTimeoutException
          - org.springframework.web.client.HttpServerErrorException$ServiceUnavailable
        ignoreExceptions:
          - com.example.BadRequestException   # 400 — never retry client errors
          - com.example.UnauthorizedException  # 401 — retrying won't help

The idempotency requirement

Retry is only safe on idempotent operations. A GET is idempotent — retrying it is always safe. A POST /orders that creates an order is not — a successful first call whose response was lost followed by a retry creates a duplicate order.

The fix: idempotency keys. The client generates a unique key (UUID) for each logical operation and sends it as a header (X-Idempotency-Key). The server stores processed keys and deduplicates:

@PostMapping("/orders")
public ResponseEntity<Order> createOrder(
        @RequestHeader("X-Idempotency-Key") String idempotencyKey,
        @RequestBody CreateOrderRequest req) {

    // Check if already processed
    Optional<Order> existing = idempotencyStore.find(idempotencyKey);
    if (existing.isPresent()) return ResponseEntity.ok(existing.get());

    Order order = orderService.create(req);
    idempotencyStore.save(idempotencyKey, order, Duration.ofHours(24));
    return ResponseEntity.status(201).body(order);
}

The production gotcha

The interaction between @Retry and @CircuitBreaker annotation order matters critically. If @Retry is the outermost annotation, it retries even when the circuit is OPEN — three pointless fast-fails before the fallback fires. The circuit breaker must be outermost:

// WRONG — retry wraps circuit breaker; retries fire even when circuit OPEN
@Retry(name = "payment")
@CircuitBreaker(name = "payment", fallbackMethod = "fallback")
public PaymentResult charge(ChargeRequest req) { ... }

// CORRECT — circuit breaker outermost; when OPEN, fails immediately, no retry
@CircuitBreaker(name = "payment", fallbackMethod = "fallback")
@Retry(name = "payment")
public PaymentResult charge(ChargeRequest req) { ... }
Interview drill-down — expect these follow-ups:
L1 → "Idempotency key is stored in Redis. What if Redis is down when you try to check it?"
Two options: (1) Fail open — process the request without checking. Risk: duplicate processing if the client retries. (2) Fail closed — reject the request with 503 until Redis recovers. Risk: temporary unavailability. For financial operations (payment creation), fail closed is safer. For low-risk operations, fail open with eventual deduplication via DB unique constraints. Never silently swallow the Redis error — always make a deliberate choice.
L2 → "Non-idempotent operation in your dependency — how do you add retry safely?"
You either make it idempotent (add idempotency keys to the outbound request), or you don't retry it at all. If the downstream doesn't support idempotency keys, you have two options: accept the risk of duplication (acceptable for low-consequence operations) or implement at-most-once semantics at your layer (never retry, record the failure, surface it to the user for manual resolution). Idempotency is a contract — you can't retry safely without it.
L3 → "What is the retry budget pattern and why is it better than maxAttempts?"
A retry budget limits the total percentage of calls that can be retries across the service, not just per-request. Example: allow retries only if total retry rate is less than 10% of total call rate. This prevents thundering-herd retries across thousands of clients from overwhelming a recovering service. Google SRE uses retry budgets extensively. In practice: implement a shared counter in Redis tracking retries vs total calls per minute; reject retry if budget is exhausted.
Trap answer to avoid: "I add retry to all my HTTP calls." — Retrying non-idempotent operations causes duplicates. Retrying on a circuit-breaker-open state wastes resources. Retrying client errors (4xx) is pointless. The correct answer: retry selectively — only idempotent operations, only on transient errors (5xx, timeouts, connection refused), with circuit breaker as the outer guard.
Q3 · Bulkhead
What is the bulkhead pattern and when is it critical?

Direct answer

The bulkhead pattern isolates resources so that a failure in one area can't exhaust resources for the whole system — named after the watertight compartments in a ship hull. In practice: allocate a separate, size-limited thread pool per downstream dependency. If the recommendation service becomes slow and blocks all 20 of its threads, your inventory and payment calls are completely unaffected — they have their own pools.

Thread-pool vs semaphore bulkhead

Thread-pool bulkhead: runs the call in a dedicated thread pool. The calling thread is free while waiting. Strong isolation — a blocked call doesn't tie up any application thread. Best for I/O-bound calls (HTTP, DB).

Semaphore bulkhead: limits concurrent calls using a semaphore on the same thread. No extra threads, lower overhead. If the semaphore is full, the call is immediately rejected. Best for quick calls or reactive stacks where thread-pool overhead is unacceptable.

# application.yml — thread-pool bulkhead
resilience4j:
  thread-pool-bulkhead:
    instances:
      recommendations:
        coreThreadPoolSize: 10
        maxThreadPoolSize: 20
        queueCapacity: 5     # beyond this, BulkheadFullException → fallback fires
        keepAliveDuration: 20ms
@Service
class ProductPageService {

    // Recommendations: optional, can degrade. Runs in isolated pool.
    @Bulkhead(name = "recommendations",
             type = Bulkhead.Type.THREADPOOL,
             fallbackMethod = "emptyRecommendations")
    public CompletableFuture<List<Product>> getRecommendations(String productId) {
        return CompletableFuture.completedFuture(recommendationClient.get(productId));
    }

    public CompletableFuture<List<Product>> emptyRecommendations(
            String id, Throwable t) {
        return CompletableFuture.completedFuture(List.of());
    }
}

The production gotcha

Bulkhead sizing is not obvious and must be profiled under load. Too small → unnecessary rejections that degrade the user experience even when the downstream is healthy. Too large → insufficient protection; slow downstream still exhausts the pool and causes cascading slowness.

Sizing approach: measure the 99th percentile response time of the downstream under normal load. Estimate peak concurrent calls to that service. Pool size ≈ peakRPS × p99LatencySeconds (Little's Law). Add 20% headroom. If the downstream's p99 is 200ms and you have 50 req/s, you need roughly 10 threads (50 × 0.2 = 10). Set maxThreadPoolSize to 15 for headroom.

Interview drill-down — expect these follow-ups:
L1 → "What happens to requests in the bulkhead queue when they finally run but the downstream is still slow?"
They run and accumulate wait time in queue + execution time. The total latency for a queued request = queue wait + actual execution. With a full queue and slow downstream, users see very high latency even after the queue drains. Pair the bulkhead with a TimeLimiter that enforces a maximum total time — if a request sits in queue too long, it's cancelled before it even runs.
L2 → "Semaphore vs thread-pool bulkhead — what concretely breaks if you choose wrong?"
Semaphore on a slow I/O call: the calling thread blocks inside the semaphore for the full duration of the I/O. You've effectively limited concurrency to semaphore-size but those threads are all parked waiting. No isolation benefit — a slow downstream still ties up your request threads. Thread-pool: calling thread submits to the pool and returns immediately (non-blocking from the caller's perspective), the pool thread does the I/O. True isolation. Always use thread-pool for I/O-bound calls.
L3 → "How do you size a bulkhead thread pool without load testing data?"
Use Little's Law: pool_size = throughput × latency. If you expect 50 req/s to that dependency and its p99 is 200ms (0.2s), you need 50 × 0.2 = 10 threads to keep up under normal load. Add 50% headroom for bursts: 15 threads. Set queue to 10–20% of pool size — small enough to reject early rather than building a deep queue that just delays failures. Revisit after first load test.
Trap answer to avoid: "I set the bulkhead queue to a large number so requests don't get rejected." — A large queue delays the rejection rather than preventing it. A slow downstream + large queue = requests waiting a long time before failing, making user-facing latency worse. A small queue that rejects early is more user-friendly than a large queue that hides the failure.
Q4 · Timeout
How do you implement timeouts correctly, and what is deadline propagation?

Direct answer

Every external call — HTTP, DB query, cache lookup — must have a timeout. Without one, a single slow response can hold a thread indefinitely. But timeouts alone are not enough: you need to propagate the remaining budget across service hops, not restart the timer at each hop. That's deadline propagation.

Connection vs read timeout, and TimeLimiter

Two distinct timeouts to configure on every HTTP client:

  • Connect timeout: how long to wait for the TCP handshake. Usually short (1–2s). Failure = network issue or service not listening.
  • Read timeout: how long to wait for a response after connecting. Set to your downstream's p99 + safety buffer.
// WebClient with timeout — recommended for new code
@Bean
public WebClient paymentWebClient() {
    HttpClient httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
        .responseTimeout(Duration.ofMillis(3000));
    return WebClient.builder()
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .baseUrl("https://payment-service")
        .build();
}

Resilience4j TimeLimiter wraps a CompletableFuture and cancels it if it exceeds the budget:

@TimeLimiter(name = "payment", fallbackMethod = "timeoutFallback")
@CircuitBreaker(name = "payment")
public CompletableFuture<PaymentResult> charge(ChargeRequest req) {
    return CompletableFuture.supplyAsync(() -> paymentClient.charge(req));
}

Deadline propagation

A timeout at each service hop is relative: "wait 5 seconds from now". If you have three serial hops each with a 5s timeout, the end-to-end latency can reach 15s even though your SLA is 10s.

A deadline is absolute: "the whole request must complete by T+500ms". Pass the remaining budget as a header. Each downstream service checks: if the deadline has already passed, fail immediately instead of doing any work.

// Outbound interceptor: propagate remaining deadline
public ClientHttpResponse intercept(HttpRequest request, ...) {
    Instant deadline = DeadlineContext.current();
    if (deadline != null) {
        request.getHeaders().set("X-Request-Deadline",
            String.valueOf(deadline.toEpochMilli()));
    }
    return execution.execute(request, body);
}

// Inbound filter: check deadline before processing
String deadlineHeader = request.getHeader("X-Request-Deadline");
if (deadlineHeader != null) {
    Instant deadline = Instant.ofEpochMilli(Long.parseLong(deadlineHeader));
    if (Instant.now().isAfter(deadline)) {
        throw new DeadlineExceededException("Request deadline already passed");
    }
}

The production gotcha

Read timeout without a circuit breaker: at 1,000 req/s with a 5s read timeout, you can accumulate 5,000 in-flight threads all waiting on a dead service. When the service comes back, all 5,000 complete at once — a thundering herd of responses that can overwhelm both services simultaneously. Always pair timeouts with circuit breakers.

Interview drill-down — expect these follow-ups:
L1 → "Deadline passed — you reject the request. But what if you already started DB work?"
The DB work continues to completion (you can't cancel a committed transaction), but you don't return the result to the caller. This means partial work was done and discarded. For idempotent reads this is fine. For writes, you must design for at-most-once or exactly-once semantics: use idempotency keys so the client can retry safely even if the server completed the work but the response was lost. Deadline expiry doesn't mean work didn't happen.
L2 → "You have three serial service hops, each with a 2s timeout. What's the worst-case end-to-end latency?"
6 seconds (3 × 2s). Plus your own processing time. This is exactly the deadline propagation problem. With a 2s deadline at the edge, propagated as X-Request-Deadline, each downstream rejects the request if deadline has passed. Worst case: your service does 500ms of work, passes 1.5s deadline to Service B, which does 500ms of work, passes 1s deadline to Service C — and C rejects because it knows it can't respond in time. Fast failure, not a slow one.
L3 → "Cancel vs interrupt — when you cancel a CompletableFuture, does it stop the running code?"
cancel(true) on a CompletableFuture sends an interrupt to the executing thread. Whether the code stops depends on whether it checks the interrupt flag. Blocking operations (Thread.sleep, blocking I/O) will throw InterruptedException and stop. Non-blocking busy loops won't stop unless they check Thread.interrupted(). TimeLimiter in Resilience4j calls cancel(true) under the hood — your downstream call will stop if it uses blocking I/O. Native HTTP clients like OkHttp and Reactor Netty respect interrupts on blocking paths.
Trap answer to avoid: "I set a generous timeout of 30 seconds to be safe." — Generous timeouts are not safe. They allow slow dependencies to hold threads for 30 seconds. At 100 req/s, that's 3,000 threads piled up against a single slow service. Short, carefully tuned timeouts (p99 + small buffer) plus circuit breakers are always safer than long timeouts.
Q5 · Rate Limiting
How do you implement rate limiting in a Spring Boot service? Walk me through the algorithms.

Direct answer

Rate limiting protects your service from being overwhelmed and enforces fair usage quotas. The key design decisions are: which algorithm, where to enforce it (per-user, per-IP, global), and whether limits are per-instance or distributed across all pods. Forgetting that last point is the most common production mistake.

The algorithms

Token bucket (most common): a bucket holds N tokens, refilled at R tokens/second. Each request consumes one token. When empty, requests are rejected. Allows bursts up to bucket size. Most natural for API rate limiting.

Fixed window: count requests in a 1-second (or 1-minute) window, reset at the boundary. Simple but has a boundary burst problem: 100 requests in the last 50ms of window 1 + 100 requests in the first 50ms of window 2 = 200 requests in 100ms despite a "100/window" limit.

Sliding window: count requests in the last N seconds (continuously, not a fixed boundary). Eliminates the boundary burst. Slightly more expensive to compute — use a Redis Sorted Set with score = timestamp.

Leaky bucket: requests enter a queue, processed at a fixed output rate. No bursts allowed at all. Predictable throughput on the output side; useful for rate-smoothing rather than rate-limiting.

Distributed rate limiting with Redis

Resilience4j's built-in RateLimiter is in-memory — each pod has its own independent limit. With 10 pods each at 100 req/s, your effective limit is 1,000 req/s. For true distributed rate limiting, use Redis with an atomic Lua script:

-- Lua script: atomic sliding-window rate limit
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])   -- window in ms
local limit = tonumber(ARGV[3])
local windowStart = now - window

redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
local count = redis.call('ZCARD', key)
if count < limit then
    redis.call('ZADD', key, now, now .. '-' .. math.random())
    redis.call('EXPIRE', key, math.ceil(window / 1000) + 1)
    return 1   -- allowed
end
return 0   -- rejected

Or use Bucket4j with Redis backend — a production-grade Java library that implements token bucket with distributed state:

@Bean
public BucketProxyManager<String> bucketManager(RedissonClient redisson) {
    ProxyManager<String> proxyManager =
        Bucket4jRedisson.casBasedBuilder(redisson).build();

    BucketConfiguration config = BucketConfiguration.builder()
        .addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofSeconds(1))))
        .build();

    return new BucketProxyManager<>(proxyManager, () -> config);
}

// Per-user rate limiting
public boolean tryConsume(String userId) {
    return bucketManager.getBucket(userId).tryConsume(1);
}

The production gotcha

The in-memory vs distributed mistake is the most common. A team adds @RateLimiter from Resilience4j in a Kubernetes deployment with 15 pods, sets the limit to 50 req/s, and wonders why the downstream is still seeing 750 req/s. Always verify whether your rate limiter is per-pod or per-service before deploying to multi-instance environments.

Interview drill-down — expect these follow-ups:
L1 → "Resilience4j's built-in RateLimiter is per-pod. How badly can this go wrong at scale?"
With 50 pods each at a 100 req/s limit, your actual throughput to the downstream is 5,000 req/s — 50x over what you intended. If the downstream's capacity is 200 req/s, 50 pods each thinking "I'm within my limit" collectively destroy it. This is one of the most common resilience mistakes in Kubernetes deployments. Always use distributed rate limiting (Redis) for per-service limits in scaled environments.
L2 → "Token bucket allows bursts. How do you handle a client that deliberately saves tokens to burst past your limits?"
Use a two-tier rate limit: a short-window limit (100/second) AND a long-window limit (1,000/minute). A client can burst to 100 in the first second, but the per-minute limit caps their cumulative throughput. Bucket4j supports multiple Bandwidth limits on the same bucket — this is the standard production pattern for abuse prevention alongside normal rate limiting.
L3 → "429 Too Many Requests with Retry-After — what's the correct Retry-After value for a sliding window limiter?"
For a sliding window: Retry-After = time until the oldest request in the window expires = (oldest_request_timestamp + window_duration) - now. The client should wait until enough old requests "slide out" of the window to bring count below the limit. For a token bucket: Retry-After = tokens_needed / refill_rate. Never return a hardcoded value — calculate it based on actual window state.
Trap answer to avoid: "I'll use Resilience4j's @RateLimiter for distributed rate limiting." — Resilience4j's RateLimiter is in-memory per JVM. It does NOT share state across pods. For distributed rate limiting you need Redis (Bucket4j + Redis, or a custom Lua script). The annotation looks identical but the semantics are completely different in a multi-pod deployment.
Q6 · Fallback
How do you design fallbacks, and when do they make things worse?

Direct answer

A fallback provides an alternative response when a call fails, allowing your service to degrade gracefully rather than returning an error. But a fallback that silently hides failures, adds its own load, or masks on-call alerts is more dangerous than returning an explicit error. The rule: every fallback must emit metrics and trigger alerts — a fallback firing is a symptom, not a resolution.

Types of fallbacks and when to use each

Static default. Return a hardcoded value or empty collection. Simplest. Use when the feature is non-critical and an empty state is safe (e.g. empty recommendations list, default configuration).

Cached fallback. Return the last-known-good value from a local or Redis cache. Use for read APIs where slightly stale data is acceptable (e.g. product catalog, exchange rates).

Degraded response. Return partial data — skip the failing component but include everything else. Use when the failing part is supplementary (e.g. page loads without personalisation if the recommendation service is down).

Fail-fast with clear error. Return an immediate, meaningful error. Use when the failing component is critical and no alternative exists (payment processing, authentication). Don't pretend to succeed when you haven't.

@CircuitBreaker(name = "inventory", fallbackMethod = "cachedInventory")
public Inventory checkInventory(String productId) {
    return inventoryClient.check(productId);
}

private Inventory cachedInventory(String productId, Throwable t) {
    metrics.increment("inventory.fallback");                       // always metric it
    log.warn("Inventory CB open for {}, serving cached", productId);

    Inventory cached = cache.get("inventory:" + productId);
    if (cached != null) return cached;

    // No cache available — fail explicitly rather than silently returning wrong data
    throw new ServiceUnavailableException("Inventory service unavailable");
}

When fallbacks make things worse

Silent data loss. Fallback returns an empty list, calling code interprets "no items" as the real state and deletes records. This has caused real production data loss incidents.

Cascading fallback load. Your service calls B, which is slow. Fallback calls C instead. C is also under higher-than-normal load (everyone is falling back to it). Now you've amplified load on C, causing it to slow down too — a cascade you created by trying to avoid one.

Masking real failures. If your fallback always succeeds, on-call is never paged. The underlying service is down, users are getting degraded experience, and no one knows. This is why fallbacks must emit metrics and alerts even when they "succeed".

Interview drill-down — expect these follow-ups:
L1 → "Cached fallback — stale data. How do you decide what staleness is acceptable?"
Business context: for product prices, a few minutes might cause revenue loss — short staleness window (2–5 min cache). For product descriptions, hours of staleness is fine. For real-time inventory, no staleness is acceptable — no cache fallback, return an error. The rule: match staleness tolerance to the business impact of serving wrong data. Document the decision explicitly so future engineers know why the TTL is what it is.
L2 → "Fallback fires but nobody is alerted. How does this become a production incident?"
Fallback "succeeds" silently → no alert fires → the failing service is not investigated → it stays degraded for hours or days → the fallback cache becomes stale beyond acceptable bounds → users see obviously wrong data. This is "silent degradation" — the system appears healthy from an uptime perspective but is actually failing. Fix: emit a metric on every fallback invocation, set an alert if fallback rate exceeds a baseline for more than N minutes.
L3 → "What is feature flagging as a fallback strategy?"
Dynamically disable a feature at runtime when its dependency is unhealthy, rather than returning degraded data. If the recommendation service is down, flip a flag to hide the recommendations section entirely — users see a clean page without the broken component, rather than an empty or stale list that might confuse them. Combine with LaunchDarkly or Spring Cloud Config for real-time flag changes without deploys.
Trap answer to avoid: "My fallback returns an empty list — that's safe." — An empty list is correct data in some contexts and wrong in others. Calling code that iterates the empty list and does nothing is fine. Code that interprets empty as "no records exist" and proceeds to delete or overwrite records is a data loss bug. Fallbacks that return empty collections must be explicitly safe by design, not assumed safe.
Q7 · Resilience4j + Spring Boot
How do you wire Resilience4j into a Spring Boot service properly, and how do you observe it?

Direct answer

Resilience4j integrates with Spring Boot via resilience4j-spring-boot3 and Spring AOP. Configuration is in application.yml, annotations are applied to beans, and metrics are auto-exported to Micrometer/Prometheus. The three most important things to get right: AOP proxy awareness, annotation ordering, and wiring state transitions to alerts.

Dependencies and configuration

<!-- pom.xml -->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>  <!-- REQUIRED -->
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
# application.yml — full config for a payment service call
resilience4j:
  circuitbreaker:
    instances:
      payment:
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 3
        slowCallRateThreshold: 80
        slowCallDurationThreshold: 2s
        registerHealthIndicator: true   # shows in /actuator/health
  retry:
    instances:
      payment:
        maxAttempts: 3
        waitDuration: 200ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        enableRandomizedWait: true
        randomizedWaitFactor: 0.5
  timelimiter:
    instances:
      payment:
        timeoutDuration: 3s

Observability — state transitions and metrics

// Wire state transitions to your alerting system
@PostConstruct
public void registerCbListeners() {
    registry.circuitBreaker("payment").getEventPublisher()
        .onStateTransition(event -> {
            String transition = event.getStateTransition().name();
            meterRegistry.counter("cb.state.transition",
                "name", "payment", "transition", transition).increment();
            if (event.getStateTransition().getToState() == State.OPEN) {
                alerting.fire("Circuit breaker OPENED: payment service");
            }
        });
}

Key Prometheus metrics auto-registered by Resilience4j:

  • resilience4j_circuitbreaker_state{name="payment"} — current state (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
  • resilience4j_circuitbreaker_calls_total{kind="failed|successful|not_permitted"}
  • resilience4j_retry_calls_total{kind="successful_with_retry|failed_with_retry"}

Actuator endpoints: /actuator/circuitbreakers, /actuator/retries, /actuator/health (if registerHealthIndicator: true).

The production gotcha — self-invocation

Resilience4j annotations use Spring AOP — the same self-invocation problem as @Transactional. Calling a @CircuitBreaker method from within the same bean via this.method() bypasses the proxy and no circuit breaking occurs. The call goes directly to the real method. Fix: always call through the injected Spring proxy (use the autowired bean reference, not this).

Interview drill-down — expect these follow-ups:
L1 → "Annotation ordering — you need TimeLimiter, Retry, Bulkhead, CircuitBreaker all on one method. What's the correct order?"
Spring AOP applies annotations from outermost to innermost: @CircuitBreaker (outermost) → @TimeLimiter → @Bulkhead → @Retry (innermost, closest to the actual call). Conceptually: CircuitBreaker decides if we attempt at all; Bulkhead limits concurrency; Retry handles transient failures; TimeLimiter cancels if too slow. In code, annotations are applied in declaration order bottom-up, so write: @CircuitBreaker, @Bulkhead, @TimeLimiter, @Retry (top to bottom = outermost to innermost).
L2 → "registerHealthIndicator: true — what shows up in /actuator/health?"
The circuit breaker state is included in the health response as a component: {"payment": {"status": "UP", "details": {"state": "CLOSED", "failureRate": "5.0%"}}}. When the circuit is OPEN the component status becomes DOWN, which rolls up to overall health status DOWN — meaning your Kubernetes readiness probe will fail and the pod is removed from the load balancer. This is sometimes desirable (stop sending traffic to a pod with open circuit) and sometimes disastrous (Kubernetes restarts a healthy pod because a dependency is failing). Consider customising health contribution if you want monitoring without affecting readiness.
L3 → "How do you test that the circuit breaker actually protects downstream in a CI integration test?"
Use WireMock with a fault injection scenario: stub the downstream to return 500s, make minimumNumberOfCalls+1 calls, assert the circuit is OPEN (registry.circuitBreaker("payment").getState() == State.OPEN), then assert subsequent calls return the fallback response immediately without hitting WireMock (verify WireMock received exactly minimumNumberOfCalls requests, not more). Advance time in WireMock to simulate the wait duration, verify the circuit transitions to HALF_OPEN.
Trap answer to avoid: "spring-boot-starter-aop is already included — I don't need to add it." — It's NOT included by default in a Spring Boot web project. Resilience4j annotations require AOP to work. Without the AOP starter, annotations silently do nothing — no circuit breaking, no retries. This is a common cause of "Resilience4j doesn't seem to be doing anything" bugs.
Q8 · Incident Response
A downstream service your microservice depends on starts failing. Walk me through your complete response.

Direct answer

This is a structured response that tests whether you think about resilience as a system property, not just a library configuration. The answer covers detection, containment, user impact, investigation, recovery, and prevention.

The full response playbook

Step 1 — Detect. Alert fires on resilience4j_circuitbreaker_state{name="payment"} == 1 (circuit OPEN) or on rising error rate from your RED dashboard. If monitoring is missing, the first signal may be user reports — which means detection is too slow.

Step 2 — Contain. Circuit breaker fast-fails new requests to the failing service — no need to intervene. Bulkhead ensures other dependencies aren't starved. Check: is the circuit breaker actually open, or are requests still flowing through and timing out?

Step 3 — Assess user impact. Is this a critical path (payment, auth) or optional feature (recommendations, analytics)? If critical: escalate, open incident, communicate SLA impact. If optional: fallback is serving degraded content — acceptable temporarily.

Step 4 — Investigate. Use distributed traces (filter by high duration and error status in your tracing tool) to confirm which span is failing. Check the downstream service's own health endpoint, logs, and metrics. Common causes: DB connection pool exhaustion, memory pressure, bad deploy, dependency of a dependency failing.

Step 5 — Communicate. If the downstream is owned by another team: open incident, tag the owners, share trace IDs and error samples. Set realistic SLA expectations with users.

Step 6 — Recover. Circuit transitions to HALF_OPEN after wait duration. Monitor probe calls — watch resilience4j_circuitbreaker_state. If probe success rate recovers, circuit closes automatically. If not, it reopens and waits again.

Step 7 — Post-mortem. Was the timeout configured correctly? Did the bulkhead prevent cascade? Did the fallback behave correctly? Did alerts fire before users noticed? What would reduce MTTD/MTTR next time?

Design principles that prevent incidents from becoming outages

  • Fail fast, not slow. A 5s timeout holding 1,000 threads is worse than an immediate failure with a fallback.
  • Never hold a DB connection while making an HTTP call. Slow HTTP → long-held connection → pool exhaustion → DB calls from other requests start failing.
  • Design for idempotency. Retry is the primary recovery mechanism. Operations that can't be retried safely severely limit your resilience options.
  • Test failure scenarios, not just happy paths. Use Testcontainers + WireMock to simulate downstream failures in CI. Use chaos engineering in staging to validate your circuit breakers actually trip and fall back correctly under realistic load.
Interview drill-down — expect these follow-ups:
L1 → "Step 6 — circuit auto-closes after probes succeed. What if it keeps flapping open/closed?"
Circuit flapping = the underlying issue is not resolved, or the waitDurationInOpenState is too short. The service recovers briefly, circuit closes, first real traffic wave causes it to reopen. Fix: increase waitDurationInOpenState (exponential increase, similar to backoff). Or investigate why the service keeps failing — flapping usually means intermittent failures (memory pressure, connection pool limit) that only manifest under load.
L2 → "You said 'never hold a DB connection while making an HTTP call'. Why specifically?"
Database connection pools are small (HikariCP default: 10). If you hold a connection while waiting for an HTTP call (which could be 100ms–2s), you're tying up a precious connection for the duration. Under load, other requests waiting for a connection pile up. When the connection pool is exhausted, even simple DB-only requests start failing. This is a cascade: one slow HTTP call can take down your entire DB interaction. Always complete DB work, commit/close the connection, then make HTTP calls.
L3 → "MTTD vs MTTR — what engineering investments reduce each most effectively?"
MTTD (Mean Time To Detect): anomaly detection on RED metrics, circuit breaker state alerts, synthetic monitoring (probes that simulate user journeys every 30s), distributed tracing with error rate dashboards. MTTR (Mean Time To Recover): runbooks linked from alerts, feature flags to disable failing components without deploys, circuit breakers that auto-recover (HALF_OPEN probes), canary deployments to limit blast radius of bad deploys. Observability reduces MTTD; automation and reversibility reduce MTTR.
Trap answer to avoid: "I'd roll back the deploy to fix it." — Rollback is not always possible (DB schema changes), not always fast (large image, slow registry), and assumes the deploy caused it. The correct first response is containment (is the circuit breaker open? is the bulkhead protecting other calls?) then investigation, then remediation. Rollback is one tool, not the default answer.