Pular para conteúdo

2. Padrões de Resiliência

O order agora chama o payment de forma síncrona: POST /orders/{id}/pay cobra o fã e, se der certo, confirma o pedido. Este passo primeiro mostra o que uma dependência lenta faz com um serviço sem proteção, depois adiciona os quatro padrões da aula de confiabilidade, um de cada vez — timeout, retry, circuit breaker, bulkhead —, medindo depois de cada um.

A falha contra a qual nos defendemos

sequenceDiagram
    participant F as fãs (20/s)
    participant O as order (50 threads)
    participant P as payment (+3 s de latência)
    F->>O: POST /orders/1/pay
    O->>P: cobrança (espera 3 s)
    F->>O: POST /orders/2/pay
    O->>P: cobrança (espera 3 s)
    Note over O: 20 req/s x 3 s = 60 chamadas em andamento > 50 threads
    F->>O: GET /orders/7 (não tem relação!)
    Note over O: nenhuma thread livre: vai para a fila, depois estoura o timeout
    O-->>F: 503 / timeout em TODO endpoint

A Lei de Little diz tudo: threads ocupadas = taxa de chegada × tempo em que cada chamada segura uma thread. Com 20 pagamentos/s e uma dependência que leva 3 s, o order precisa de 60 threads só para esperar. Quando o pool (50 aqui, 200 por padrão) acaba, todo endpoint do order para — inclusive o GET /orders/{id}, que nunca toca o payment. Uma dependência lenta derrubou o serviço inteiro: uma falha em cascata.

Por que o order tem só 50 threads neste lab

O pool padrão do Tomcat tem 200 threads. Um laptop rodando catorze contêineres não consegue gerar carga suficiente para esgotar 200 threads contra uma dependência de 3 segundos sem que o próprio k6 vire o gargalo, então o lab encolhe o pool (server.tomcat.threads.max: 50) — o mesmo truque do lab 1, quando limitou o catalog a uma CPU. O mecanismo é idêntico em qualquer tamanho.

Código

Adicione o Resilience4j ao order (o seu módulo para Spring Boot 4, resilience4j-spring-boot4); as anotações precisam do AspectJ (spring-boot-starter-aspectj). O pom.xml completo:

services/order/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>order</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-restclient</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- lab 8: only the API (no SDK); the Java agent provides the implementation at runtime
             and, without the agent, every call is a no-op. Version managed by Spring Boot. -->
        <dependency>
            <groupId>io.opentelemetry</groupId>
            <artifactId>opentelemetry-api</artifactId>
        </dependency>
        <!-- lab 9: resilience patterns (the annotations need AspectJ) -->
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-boot4</artifactId>
            <version>2.4.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aspectj</artifactId>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

O novo pacote boxoffice.order.payment contém o cliente e o gateway protegido:

services/order/src/main/java/boxoffice/order/payment/PaymentRequest.java
package boxoffice.order.payment;

import java.math.BigDecimal;
import java.util.UUID;

public record PaymentRequest(UUID orderId, BigDecimal amount) {
}
services/order/src/main/java/boxoffice/order/payment/PaymentResponse.java
package boxoffice.order.payment;

import java.util.UUID;

/** status: AUTHORIZED or DECLINED */
public record PaymentResponse(UUID id, UUID orderId, String status) {
}
services/order/src/main/java/boxoffice/order/payment/PaymentClient.java
package boxoffice.order.payment;

import java.time.Duration;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class PaymentClient {

    private final RestClient http;

    public PaymentClient(RestClient.Builder builder,
                         @Value("${boxoffice.payment.url}") String url,
                         @Value("${boxoffice.payment.connect-timeout}") Duration connectTimeout,
                         @Value("${boxoffice.payment.read-timeout}") Duration readTimeout) {
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(connectTimeout);
        factory.setReadTimeout(readTimeout); // 0 = wait forever (the "before" experiment)
        this.http = builder.baseUrl(url).requestFactory(factory).build();
    }

    public PaymentResponse charge(UUID orderId, java.math.BigDecimal amount) {
        return http.post()
                .uri("/payments")
                .header("Idempotency-Key", "order-" + orderId) // same key on every retry
                .body(new PaymentRequest(orderId, amount))
                .retrieve()
                .body(PaymentResponse.class);
    }
}
services/order/src/main/java/boxoffice/order/payment/PaymentGateway.java
package boxoffice.order.payment;

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import java.math.BigDecimal;
import java.util.UUID;
import org.springframework.stereotype.Component;

/**
 * Default aspect order: Retry ( CircuitBreaker ( Bulkhead ( call ) ) ).
 * The fallback sits on the OUTERMOST layer, so it runs only after the retries are exhausted,
 * when the circuit is open or when the bulkhead is full.
 */
@Component
public class PaymentGateway {

    private final PaymentClient client;

    public PaymentGateway(PaymentClient client) {
        this.client = client;
    }

    @Retry(name = "payment", fallbackMethod = "unavailable")
    @CircuitBreaker(name = "payment")
    @Bulkhead(name = "payment")
    public PaymentResponse charge(UUID orderId, BigDecimal amount) {
        return client.charge(orderId, amount);
    }

    /** The "before" path of the experiments: no Resilience4j at all. */
    public PaymentResponse chargeUnprotected(UUID orderId, BigDecimal amount) {
        return client.charge(orderId, amount);
    }

    /** Same parameters + the exception: Resilience4j finds it by name and signature. */
    private PaymentResponse unavailable(UUID orderId, BigDecimal amount, Throwable cause) {
        throw new PaymentUnavailableException(cause);
    }
}
services/order/src/main/java/boxoffice/order/payment/PaymentUnavailableException.java
package boxoffice.order.payment;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

/** Fast, explicit failure: the fan sees "try again in a moment", not a 60-second spinner. */
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public class PaymentUnavailableException extends RuntimeException {

    public PaymentUnavailableException(Throwable cause) {
        super("payment temporarily unavailable: " + cause.getClass().getSimpleName(), cause);
    }
}

O OrderService ganha o pay. Repare onde está a transação: a chamada remota acontece sem transação aberta, e só a curta atualização de status roda dentro de uma — segurar uma conexão do banco enquanto se espera 3 s por uma dependência levaria a cascata para o pool de conexões.

services/order/src/main/java/boxoffice/order/OrderService.java
package boxoffice.order;

import boxoffice.order.OrderProperties.NotifyMode;
import boxoffice.order.outbox.Outbox;
import boxoffice.order.payment.PaymentGateway;
import boxoffice.order.payment.PaymentResponse;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;

@Service
public class OrderService {

    public record PlaceResult(OrderView order, boolean created) {
    }

    private static final String SELECT = """
            SELECT id, show_id, hold_id, customer_email, amount, status, created_at
            FROM orders
            """;

    private final JdbcClient jdbc;
    private final Outbox outbox;
    private final NotificationClient notifications;
    private final OrderProperties props;
    private final PaymentGateway payments;
    private final TransactionTemplate tx;
    private final boolean resilience;

    public OrderService(JdbcClient jdbc, Outbox outbox, NotificationClient notifications,
                        OrderProperties props, PaymentGateway payments, TransactionTemplate tx,
                        @Value("${boxoffice.payment.resilience:true}") boolean resilience) {
        this.jdbc = jdbc;
        this.outbox = outbox;
        this.notifications = notifications;
        this.props = props;
        this.payments = payments;
        this.tx = tx;
        this.resilience = resilience;
    }

    @Transactional
    public PlaceResult place(OrderRequest req) {
        int inserted = jdbc.sql("""
                INSERT INTO orders (id, show_id, hold_id, customer_email, amount, status)
                VALUES (?, ?, ?, ?, ?, 'PENDING_PAYMENT')
                ON CONFLICT (hold_id) DO NOTHING
                """)
                .params(UUID.randomUUID(), req.showId(), req.holdId(), req.customerEmail(), req.amount())
                .update();

        OrderView order = jdbc.sql(SELECT + " WHERE hold_id = ?")
                .param(req.holdId())
                .query(OrderView.class)
                .single();

        if (inserted == 0) {
            return new PlaceResult(order, false);
        }

        OrderEvent event = OrderEvent.placed(order);
        if (props.notifyMode() == NotifyMode.SYNC) {
            notifications.send(event);
        } else {
            outbox.append(event.eventId(), "order", order.id(), event.eventType(), event);
        }
        return new PlaceResult(order, true);
    }

    /** No @Transactional here: never hold a DB connection during a remote call. */
    public OrderView pay(UUID id) {
        OrderView order = find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        if (!"PENDING_PAYMENT".equals(order.status())) {
            return order; // already paid or cancelled: paying twice is a no-op
        }
        PaymentResponse payment = resilience
                ? payments.charge(order.id(), order.amount())
                : payments.chargeUnprotected(order.id(), order.amount());
        String next = "AUTHORIZED".equals(payment.status()) ? "CONFIRMED" : "CANCELLED";

        tx.executeWithoutResult(status -> {
            int changed = jdbc.sql("UPDATE orders SET status = ? WHERE id = ? AND status = 'PENDING_PAYMENT'")
                    .params(next, id)
                    .update();
            if (changed == 1) {
                String type = "CONFIRMED".equals(next) ? "OrderConfirmed" : "OrderCancelled";
                OrderEvent event = new OrderEvent(UUID.randomUUID(), type, order.id(), order.showId(),
                        order.holdId(), order.customerEmail(), order.amount(), Instant.now());
                outbox.append(event.eventId(), "order", order.id(), type, event);
            }
        });
        return find(id).orElseThrow();
    }

    public Optional<OrderView> find(UUID id) {
        return jdbc.sql(SELECT + " WHERE id = ?").param(id).query(OrderView.class).optional();
    }
}
services/order/src/main/java/boxoffice/order/OrderController.java
package boxoffice.order;

import boxoffice.order.waitingroom.WaitingRoom;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orders;
    private final WaitingRoom waitingRoom;

    public OrderController(OrderService orders, WaitingRoom waitingRoom) {
        this.orders = orders;
        this.waitingRoom = waitingRoom;
    }

    @PostMapping
    public ResponseEntity<OrderView> place(
            @Valid @RequestBody OrderRequest req,
            @RequestHeader(name = "X-Admission-Ticket", required = false) String ticket) {
        if (!waitingRoom.mayEnter(req.showId(), ticket)) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN,
                    "on-sale in progress: join /orders/waiting-room/" + req.showId() + "/join first");
        }
        OrderService.PlaceResult result = orders.place(req);
        if (!result.created()) {
            return ResponseEntity.ok(result.order());
        }
        return ResponseEntity.created(URI.create("/orders/" + result.order().id())).body(result.order());
    }

    @GetMapping("/{id}")
    public OrderView get(@PathVariable UUID id) {
        return orders.find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    @PostMapping("/{id}/pay")
    public OrderView pay(@PathVariable UUID id) {
        return orders.pay(id);
    }
}

Reconstrua o order e pague um pedido de ponta a ponta (na raiz do repositório):

docker compose up -d --build --wait order
ID=$(curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \
  -d '{"showId":"show-9","holdId":"hold-pay-1","customerEmail":"bia@example.com","amount":350.00}' | jq -r .id)
curl -s -X POST localhost:8084/orders/$ID/pay

Você deve ver o pedido com "status":"CONFIRMED". O primeiro pagamento depois de um restart pode responder 503: as JVMs a frio do order, do payment e do WireMock podem precisar de mais que o timeout de leitura de 1 s na primeiríssima chamada — é o fallback em ação. Chame o pay de novo: o payment já guardou a cobrança sob a mesma chave de idempotência, então a segunda chamada responde na hora com CONFIRMED (sem segunda cobrança).

O UPDATE … WHERE status = 'PENDING_PAYMENT' também torna a confirmação idempotente: duas chamadas pay concorrentes para o mesmo pedido podem chegar ambas ao payment (que deduplica pela chave), mas só uma muda o status e grava o evento OrderConfirmed.

Configuração

Todos os ajustes ficam no application.yaml (o arquivo completo; as partes do lab 9 são server.tomcat, boxoffice.payment, resilience4j e os novos endpoints do actuator); as variáveis de ambiente permitem desligá-los um a um nos experimentos, sem reconstruir.

services/order/src/main/resources/application.yaml
server:
  port: 8084
  tomcat:
    threads:
      max: 50                        # lab 9: small on purpose, to make thread exhaustion visible

spring:
  application:
    name: order
  mvc:
    problemdetails:
      enabled: true
  datasource:
    url: jdbc:postgresql://${DB_HOST:db}:${DB_PORT:5432}/${DB_NAME:boxoffice}?currentSchema=orders
    username: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
  flyway:
    schemas: orders
  data:
    redis:
      host: ${REDIS_HOST:redis}
      port: 6379
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:kafka:9092}
    producer:
      acks: all
      properties:
        enable.idempotence: true
        linger.ms: 5
        max.block.ms: 5000     # Kafka down: fail the relay run in 5 s (default 60 s) and retry

boxoffice:
  order:
    notify-mode: ${NOTIFY_MODE:outbox}
    notification-url: ${NOTIFICATION_URL:http://notification:8086}
    topic: order-events
    relay-interval-ms: 200
  waiting-room:
    admit-per-second: ${ADMIT_PER_SECOND:20}
    admission-ttl: 5m
  payment:                           # lab 9
    url: ${PAYMENT_URL:http://payment:8085}
    resilience: ${PAYMENT_RESILIENCE:true}   # false = call payment with no Resilience4j at all
    connect-timeout: 500ms
    read-timeout: ${PAYMENT_READ_TIMEOUT:1s} # 0 = wait forever

resilience4j:
  retry:
    instances:
      payment:
        max-attempts: ${PAYMENT_RETRY_ATTEMPTS:3}    # 1 = no retry
        wait-duration: 200ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2            # 200 ms, 400 ms ...
        enable-randomized-wait: true
        randomized-wait-factor: 0.5                  # ... each +/- 50% (jitter)
        retry-exceptions:
          - org.springframework.web.client.ResourceAccessException   # timeouts, connection refused/reset
          - org.springframework.web.client.HttpServerErrorException  # 5xx
          - org.springframework.web.client.RestClientException       # connection reset while reading the response
        ignore-exceptions:
          - org.springframework.web.client.HttpClientErrorException  # 4xx: the same request fails again
          - io.github.resilience4j.circuitbreaker.CallNotPermittedException  # circuit open: fail fast
          - io.github.resilience4j.bulkhead.BulkheadFullException           # bulkhead full: fail fast
  circuitbreaker:
    instances:
      payment:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20
        minimum-number-of-calls: 10
        failure-rate-threshold: 50                   # open when >= 50% of the last 20 calls failed...
        slow-call-duration-threshold: 800ms
        slow-call-rate-threshold: 50                 # ... or >= 50% were slower than 800 ms
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 3
        automatic-transition-from-open-to-half-open-enabled: true
        register-health-indicator: true
        ignore-exceptions:
          - io.github.resilience4j.bulkhead.BulkheadFullException  # our own refusal is not the dependency's fault
  bulkhead:
    instances:
      payment:
        max-concurrent-calls: ${PAYMENT_BULKHEAD:10} # at most 10 of the 50 threads wait on payment
        max-wait-duration: 0                         # full = reject immediately

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,circuitbreakers,circuitbreakerevents,retries,bulkheads
  endpoint:
    health:
      probes:
        enabled: true        # /actuator/health/liveness and /readiness
  health:
    circuitbreakers:
      enabled: true          # lab 9: breaker state in /actuator/health

Padrão por padrão

Cada experimento usa o script do k6 do passo 4 (load/pay-under-chaos.js): 20 pagamentos por segundo mais 20 GET /orders/{id} por segundo, por 2 minutos. Reconstrua o order uma vez (docker compose up -d --build --wait order) e depois mude só variáveis de ambiente.

0. Sem proteção

PAYMENT_RESILIENCE=false PAYMENT_READ_TIMEOUT=0 docker compose up -d --wait order
curl -s -X POST localhost:8474/proxies/payment/toxics \
  -d '{"name":"slow","type":"latency","stream":"downstream","attributes":{"latency":3000}}'
k6 run load/pay-under-chaos.js

Observe o cenário browse: o GET /orders/{id} — que não chama o payment — degrada junto com o pay. Essa é a cascata.

1. Timeout

PAYMENT_RESILIENCE=false PAYMENT_READ_TIMEOUT=1s docker compose up -d --wait order
k6 run load/pay-under-chaos.js

Agora todo pagamento falha depois de 1 s em vez de travar por 3 s: uma thread fica presa 1 s, não 3 s, então 20/s × 1 s = 20 threads — dentro do pool. O GET /orders/{id} se recupera. Um timeout converte uma falha lenta numa falha rápida, e falhas rápidas não se propagam em cascata.

Os timeouts precisam ser menores que o de quem chama

A borda ou o gateway na frente do order tem o seu próprio timeout. Se o order espera o payment por mais tempo do que o gateway espera o order, o gateway desiste primeiro e o order continua trabalhando para ninguém. Os orçamentos encolhem a cada salto: borda 10 s > gateway 5 s > order→payment 1 s × retries.

2. Retry com backoff e jitter

Troque a latência por uma rede instável — 30% das conexões são resetadas:

curl -s -X DELETE localhost:8474/proxies/payment/toxics/slow
curl -s -X POST localhost:8474/proxies/payment/toxics \
  -d '{"name":"flaky","type":"reset_peer","toxicity":0.3,"attributes":{"timeout":0}}'
PAYMENT_RETRY_ATTEMPTS=1 docker compose up -d --wait order    # resilience on, retry off
k6 run load/pay-under-chaos.js
PAYMENT_RETRY_ATTEMPTS=3 docker compose up -d --wait order
k6 run load/pay-under-chaos.js

Com uma tentativa, uma parte dos pagamentos falha (não exatamente 30%: a toxicity se aplica por conexão TCP, e as conexões saudáveis são reutilizadas). Com três tentativas, um pagamento só falha se as três falharem: se uma tentativa falha com probabilidade p, três falham com p³ — 30% vira 2,7%. Numa execução de teste deste lab (segundas rodadas, já que a primeira depois de recriar também mostra as rejeições do bulkhead com a JVM fria), cerca de 0,5% dos pagamentos falharam com uma tentativa e 0,1% com três: o tóxico só atinge conexões novas. Um reset pode chegar enquanto o order lê a resposta, o que o RestClient informa como uma RestClientException simples — por isso o application.yaml a lista entre as retry-exceptions, ao lado da ResourceAccessException, enquanto a HttpClientErrorException (4xx) é ignorada. Retries corrigem erros transitórios — ao preço de carga extra na dependência (até 3×) e latência extra (o backoff). Eles só são seguros porque a requisição carrega uma chave de idempotência.

Por que jitter?

Sem ele, todo cliente que falhou no mesmo instante tenta de novo no mesmo instante (200 ms depois, depois 400 ms depois): ondas sincronizadas que atingem uma dependência em recuperação como um martelo. Um aleatório de ±50% espalha os retries no tempo. Experimente o cenário retry-storm no playground para ver a amplificação.

3. Circuit breaker

Agora uma queda total: desative o proxy, para que toda conexão ao payment seja recusada.

curl -s -X DELETE localhost:8474/proxies/payment/toxics/flaky
curl -s -X POST localhost:8474/proxies/payment -d '{"enabled": false}'
k6 run load/pay-under-chaos.js                                                          # terminal 1
watch -n 1 "curl -s localhost:8084/actuator/circuitbreakers | jq '.circuitBreakers.payment.state'"   # terminal 2

Depois de 10 chamadas com falha (o minimum-number-of-calls) o breaker abre: pelos próximos 10 s, o charge falha imediatamente com CallNotPermittedException — sem chamada de rede, sem retry, sem espera — e o fallback responde 503. Depois ele passa a half-open, deixa passar 3 chamadas de teste e fecha de novo se elas derem certo. Reative o proxy no meio da execução (curl -s -X POST localhost:8474/proxies/payment -d '{"enabled": true}') e veja-o fechar. O /actuator/circuitbreakerevents lista cada transição.

O breaker protege os dois lados: o order para de gastar threads e tempo em chamadas que vão falhar, e o payment ganha fôlego para se recuperar em vez de uma enxurrada de retries.

4. Bulkhead

De volta à latência, mas desta vez com chamadas que dão certo devagar — 700 ms: abaixo do timeout de leitura (1 s) e, sozinhas, abaixo do limite de chamada lenta do circuit breaker (800 ms) — e em maior número: 70 pagamentos por segundo.

curl -s -X POST localhost:8474/proxies/payment -d '{"enabled": true}'
curl -s -X POST localhost:8474/proxies/payment/toxics \
  -d '{"name":"slow","type":"latency","stream":"downstream","attributes":{"latency":700}}'
PAYMENT_BULKHEAD=1000 docker compose up -d --wait order   # effectively no bulkhead
k6 run -e PAY_RATE=70 load/pay-under-chaos.js
PAYMENT_BULKHEAD=10 docker compose up -d --wait order
k6 run -e PAY_RATE=70 load/pay-under-chaos.js
curl -s -X POST localhost:8474/reset                # remove every toxic

Sem bulkhead, 70/s × 0,7 s ≈ 49 threads esperam o payment: o pool de 50 fica praticamente cheio e o GET /orders/{id} entra na fila atrás dos pagamentos. Com max-concurrent-calls: 10, no máximo 10 threads esperam o payment; os pagamentos concorrentes excedentes são rejeitados na hora (503), e as outras 40 threads continuam servindo o GET /orders/{id} com latência normal.

Numa execução de teste deste lab (segundas rodadas), as chamadas que passaram sem o bulkhead levaram 750–1000 ms — 700 ms mais o tempo do próprio payment sob essa carga —, então a regra de chamada lenta do breaker o abriu em parte da execução e cerca de metade dos pagamentos falhou rápido; mesmo assim, o p95 do browse ficou em cerca de 0,75 s. Com o bulkhead, cerca de 85% dos pagamentos foram rejeitados (10 chamadas / 0,7 s ≈ 14 dos 70 por segundo cabem) e o p95 do browse ficou em cerca de 25 ms. Um bulkhead é um orçamento de concorrência por dependência — o nome vem dos compartimentos estanques de um navio.

Padrão Protege contra Custo
Timeout esperar para sempre por uma dependência lenta algumas chamadas lentas, mas bem-sucedidas, viram erros
Retry + backoff + jitter erros transitórios mais carga na dependência, mais latência; exige idempotência
Circuit breaker martelar uma dependência que está fora do ar rejeita chamadas durante o wait-duration mesmo que ela tenha se recuperado antes
Bulkhead uma dependência consumir todas as threads rejeita chamadas acima do limite mesmo com threads livres em outro lugar
Fallback mostrar um erro cru precisa ser honesto: "tente de novo", não um falso sucesso

Na entrevista

"Toda chamada remota tem um timeout menor que o de quem a chama, retries só em erros transitórios, com backoff exponencial e jitter, em operações idempotentes, atrás de um circuit breaker; cada dependência tem o seu próprio bulkhead, para que uma dependência lenta não esgote o serviço."

Próximo: Game day