1. Pagamento, Provedor e Toxiproxy
O BoxOffice não movimenta dinheiro sozinho: o serviço payment é um adaptador para um provedor externo de serviços de pagamento (PSP, payment service provider). Isso faz dele a dependência mais perigosa do checkout — lento num dia movimentado, fora do seu controle, e o lugar onde um retry pode cobrar um fã duas vezes. Este passo constrói as três peças que o resto do lab quebra de propósito:
| Contêiner | Imagem / build | Porta | Papel |
|---|---|---|---|
psp | wiremock/wiremock:3.13.2 | 8080 (interna) | um provedor externo falso: POST /v1/charges responde approved |
payment | services/payment (Spring Boot) | 8085 | adaptador: POST /payments idempotente, chama o PSP com timeouts |
toxiproxy | ghcr.io/shopify/toxiproxy:2.13.0 | 8474 (API) | proxy TCP na frente do payment e do psp; injeta latência, resets, quedas |
flowchart LR
order[order] -->|"PAYMENT_URL = toxiproxy:18085"| tp1["toxiproxy proxy: payment"]
tp1 --> payment[payment :8085]
payment -->|"PSP_URL = toxiproxy:18095"| tp2["toxiproxy proxy: psp"]
tp2 --> psp["psp (WireMock)"]
payment --> db[(db: schema payment)]
you[você / game day] -->|"curl :8474 adiciona toxic"| tp1 Toda chamada atravessa um proxy do Toxiproxy, então você consegue deixar qualquer salto lento ou quebrado sem tocar no código: o Toxiproxy trabalha no nível TCP, abaixo do HTTP, exatamente onde as redes reais falham.
O provedor falso
O WireMock responde HTTP a partir de arquivos JSON de stub. Um stub basta: aprovar toda cobrança e inventar uma referência do provedor.
{
"request": {
"method": "POST",
"urlPath": "/v1/charges"
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"jsonBody": {
"id": "ch_{{randomValue length=12 type='ALPHANUMERIC'}}",
"status": "approved"
},
"transformers": ["response-template"]
}
}
O serviço payment
Um serviço Spring Boot em services/payment/, porta 8085, servindo em /payments, schema payment. Os arquivos de build seguem o mesmo padrão do notification do lab 7, sem Kafka nem e-mail.
services/payment/
├── Dockerfile
├── .dockerignore
├── pom.xml
└── src/main/
├── java/boxoffice/payment/
│ ├── PaymentApplication.java
│ ├── PaymentController.java
│ ├── PaymentService.java
│ ├── PspClient.java
│ ├── ChargeRequest.java
│ └── PaymentOut.java
└── resources/
├── application.yaml
└── db/migration/V1__create_payments.sql
<?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>payment</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-actuator</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>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
# syntax=docker/dockerfile:1
# same Dockerfile as catalog (lab 1); only the port changes
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8085
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
server:
port: 8085
spring:
application:
name: payment
mvc:
problemdetails:
enabled: true
datasource:
url: jdbc:postgresql://${DB_HOST:db}:${DB_PORT:5432}/${DB_NAME:boxoffice}?currentSchema=payment
username: ${DB_USER:boxoffice}
password: ${DB_PASSWORD:boxoffice}
flyway:
schemas: payment
boxoffice:
psp:
url: ${PSP_URL:http://psp:8080}
connect-timeout: 500ms
read-timeout: ${PSP_READ_TIMEOUT:2s}
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
probes:
enabled: true
CREATE TABLE payments (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(100) NOT NULL UNIQUE, -- one charge per key, whatever the retries
order_id UUID NOT NULL,
amount NUMERIC(10,2) NOT NULL,
status VARCHAR(20) NOT NULL, -- AUTHORIZED | DECLINED
provider_ref VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
package boxoffice.payment;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.util.UUID;
public record ChargeRequest(@NotNull UUID orderId, @NotNull @Positive BigDecimal amount) {
}
package boxoffice.payment;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
public record PaymentOut(
UUID id,
UUID orderId,
BigDecimal amount,
String status,
String providerRef,
OffsetDateTime createdAt) {
}
package boxoffice.payment;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PaymentApplication {
public static void main(String[] args) {
SpringApplication.run(PaymentApplication.class, args);
}
}
O cliente do provedor define os dois timeouts explicitamente. Sem eles, um cliente HTTP Java espera uma resposta para sempre — a causa raiz da maioria das falhas em cascata.
package boxoffice.payment;
import java.math.BigDecimal;
import java.time.Duration;
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 PspClient {
record ChargeBody(BigDecimal amount, String currency, String reference) {
}
record Charge(String id, String status) {
}
private final RestClient http;
public PspClient(RestClient.Builder builder,
@Value("${boxoffice.psp.url}") String url,
@Value("${boxoffice.psp.connect-timeout}") Duration connectTimeout,
@Value("${boxoffice.psp.read-timeout}") Duration readTimeout) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(connectTimeout); // time to open the TCP connection
factory.setReadTimeout(readTimeout); // max silence while waiting for the response
this.http = builder.baseUrl(url).requestFactory(factory).build();
}
/** The provider deduplicates by Idempotency-Key, like real PSPs do. */
public Charge charge(String idempotencyKey, BigDecimal amount, String reference) {
return http.post()
.uri("/v1/charges")
.header("Idempotency-Key", idempotencyKey)
.body(new ChargeBody(amount, "BRL", reference))
.retrieve()
.body(Charge.class);
}
}
package boxoffice.payment;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
public record Result(PaymentOut payment, boolean created) {
}
private final JdbcClient jdbc;
private final PspClient psp;
public PaymentService(JdbcClient jdbc, PspClient psp) {
this.jdbc = jdbc;
this.psp = psp;
}
public Result charge(String idempotencyKey, ChargeRequest req) {
Optional<PaymentOut> done = find(idempotencyKey);
if (done.isPresent()) {
return new Result(done.get(), false); // a retry: answer, do not charge again
}
// remote call OUTSIDE any database transaction: never hold a connection while waiting
PspClient.Charge charge = psp.charge(idempotencyKey, req.amount(), "order-" + req.orderId());
String status = "approved".equals(charge.status()) ? "AUTHORIZED" : "DECLINED";
jdbc.sql("""
INSERT INTO payments (id, idempotency_key, order_id, amount, status, provider_ref)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (idempotency_key) DO NOTHING
""")
.params(UUID.randomUUID(), idempotencyKey, req.orderId(), req.amount(), status, charge.id())
.update();
return new Result(find(idempotencyKey).orElseThrow(), true);
}
private Optional<PaymentOut> find(String idempotencyKey) {
return jdbc.sql("""
SELECT id, order_id, amount, status, provider_ref, created_at
FROM payments WHERE idempotency_key = ?
""")
.param(idempotencyKey)
.query(PaymentOut.class)
.optional();
}
}
package boxoffice.payment;
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.ResponseEntity;
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;
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final PaymentService payments;
public PaymentController(PaymentService payments) {
this.payments = payments;
}
@PostMapping
public ResponseEntity<PaymentOut> charge(@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ChargeRequest req) {
PaymentService.Result r = payments.charge(idempotencyKey, req);
return r.created()
? ResponseEntity.created(URI.create("/payments/" + r.payment().id())).body(r.payment())
: ResponseEntity.ok(r.payment());
}
}
Idempotência de ponta a ponta
A mesma chave viaja order → payment → PSP: o order envia Idempotency-Key: order-<id>, o payment a guarda com uma restrição UNIQUE e a repassa ao provedor. Um retry em qualquer camada — o Resilience4j no order, um timeout no gateway, um fã clicando duas vezes — encontra a chave e recebe de volta o primeiro resultado. Retries só são seguros sobre idempotência; este é o estudo de caso do sistema de pagamentos em miniatura.
Toxiproxy
O Toxiproxy lê os seus proxies de um arquivo JSON na inicialização:
[
{ "name": "payment", "listen": "0.0.0.0:18085", "upstream": "payment:8085", "enabled": true },
{ "name": "psp", "listen": "0.0.0.0:18095", "upstream": "psp:8080", "enabled": true }
]
Compose
psp:
image: wiremock/wiremock:3.13.2
volumes:
- ./infra/psp:/home/wiremock:ro
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.13.0
command: ["-host=0.0.0.0", "-config=/config/toxiproxy.json"]
volumes:
- ./infra/toxiproxy/toxiproxy.json:/config/toxiproxy.json:ro
ports:
- "8474:8474"
payment:
build: ./services/payment
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: payment
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
PSP_URL: http://toxiproxy:18095 # the provider, through Toxiproxy
ports:
- "8085:8085"
depends_on:
db:
condition: service_healthy
toxiproxy:
condition: service_started
psp:
condition: service_started
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8085/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
e o serviço order agora chega ao payment pelo proxy (não diretamente), com os ajustes do passo 2 como variáveis:
PAYMENT_URL: http://toxiproxy:18085 # lab 9: through Toxiproxy, not payment:8085
PAYMENT_RESILIENCE: ${PAYMENT_RESILIENCE:-true}
PAYMENT_READ_TIMEOUT: ${PAYMENT_READ_TIMEOUT:-1s}
PAYMENT_RETRY_ATTEMPTS: ${PAYMENT_RETRY_ATTEMPTS:-3}
PAYMENT_BULKHEAD: ${PAYMENT_BULKHEAD:-10}
Pegue o arquivo completo (o do lab 8 mais estes serviços e variáveis), na raiz do repositório:
curl -fsSL -o docker-compose.yaml https://insper.github.io/platform/2027.1/hands-on/9/code/docker-compose.yaml
docker-compose.yaml — o arquivo completo ao fim deste lab
name: boxoffice
# lab 8: OpenTelemetry settings shared by every Java service (merged into each "environment")
x-otel: &otel
JAVA_TOOL_OPTIONS: -javaagent:/otel/opentelemetry-javaagent.jar
OTEL_JAVAAGENT_ENABLED: ${OTEL_ON:-true} # false = same image, agent off
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-lgtm:4318
OTEL_METRIC_EXPORT_INTERVAL: "10000" # 10 s instead of 60 s
OTEL_RESOURCE_ATTRIBUTES: service.namespace=boxoffice,deployment.environment.name=local
OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_always_on}
OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0}
LOGGING_PATTERN_CORRELATION: "[%X{trace_id:-},%X{span_id:-}] " # trace id in console logs
# settings shared by the three catalog replicas (a YAML anchor)
x-catalog: &catalog
build: ./services/catalog
image: boxoffice-catalog
volumes:
- ./infra/otel:/otel:ro # lab 8: the Java agent
environment:
<<: *otel
OTEL_SERVICE_NAME: catalog # three replicas, ONE service
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
REDIS_HOST: redis
REDIS_PORT: 6379
CACHE_ENABLED: ${CACHE_ENABLED:-true}
CACHE_TTL_EVENT: ${CACHE_TTL_EVENT:-60s}
CACHE_TTL_LIST: ${CACHE_TTL_LIST:-10s}
CACHE_STAMPEDE_PROTECTION: ${CACHE_STAMPEDE_PROTECTION:-false}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s # lab 8: with the agent, JVMs starting together need up to ~90 s
retries: 3
services:
db:
image: postgres:17
command: ["postgres", "-c", "max_connections=${DB_MAX_CONNECTIONS:-100}"]
environment:
POSTGRES_DB: ${DB_NAME:-boxoffice}
POSTGRES_USER: ${DB_USER:-boxoffice}
POSTGRES_PASSWORD: ${DB_PASSWORD:-boxoffice}
REPLICATION_PASSWORD: ${REPLICATION_PASSWORD:-replicator}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
# runs only when the volume is new: creates the "replicator" user
- ./infra/postgres/10-replication.sh:/docker-entrypoint-initdb.d/10-replication.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
# read-only streaming replica of db (hot standby)
db-replica:
image: postgres:17
user: postgres
entrypoint: ["/usr/local/bin/replica-entrypoint.sh"]
environment:
PGPASSWORD: ${REPLICATION_PASSWORD:-replicator} # used by pg_basebackup
REPLICA_DELAY: ${REPLICA_DELAY:-0} # e.g. 2s: artificial lag
ports:
- "5433:5432"
volumes:
- replica-data:/var/lib/postgresql/data
- ./infra/postgres/replica-entrypoint.sh:/usr/local/bin/replica-entrypoint.sh:ro
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -U boxoffice -d boxoffice"]
interval: 5s
timeout: 3s
retries: 20
pgbouncer:
image: edoburu/pgbouncer:v1.26.0-p0
ports:
- "6432:6432"
volumes:
- ./infra/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
- ./infra/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro
depends_on:
db:
condition: service_healthy
redis:
image: redis:8-alpine
# a pure cache: bounded memory, evict least-recently-used keys, no persistence
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru",
"--save", "", "--appendonly", "no"]
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
# three identical replicas; no "ports:" — only the edge is reachable from outside
catalog-1:
<<: *catalog
hostname: catalog-1
catalog-2:
<<: *catalog
hostname: catalog-2
catalog-3:
<<: *catalog
hostname: catalog-3
inventory:
build: ./services/inventory
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: inventory
DB_HOST: ${INVENTORY_DB_HOST:-pgbouncer} # writes go through PgBouncer...
DB_PORT: ${INVENTORY_DB_PORT:-6432}
DB_MIGRATION_HOST: db # ...migrations straight to the primary
DB_REPLICA_HOST: ${DB_REPLICA_HOST:-db-replica}
DB_REPLICA_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${INVENTORY_POOL_SIZE:-20}
INVENTORY_LOCKING: ${INVENTORY_LOCKING:-pessimistic}
INVENTORY_HOLD_TTL: ${INVENTORY_HOLD_TTL:-PT10M}
ports:
- "8083:8083" # published for experiments; clients go through the edge
depends_on:
db:
condition: service_healthy
db-replica:
condition: service_healthy
pgbouncer:
condition: service_started
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
auth:
build: ./services/auth
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: auth
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
JWT_TTL: ${JWT_TTL:-PT1H}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@boxoffice.dev}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-boxoffice-admin}
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
gateway:
build: ./services/gateway
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: gateway
AUTH_URL: http://auth:8081
INVENTORY_URL: http://inventory:8083
AUTH_JWKS_URI: http://auth:8081/auth/.well-known/jwks.json
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
ports:
- "8080:8080" # published for experiments; clients use the edge (port 80)
depends_on:
- auth
- inventory
- catalog-1
- catalog-2
- catalog-3
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
edge:
image: nginx:1.30-alpine
ports:
- "80:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- gateway
# ---- lab 7: Kafka (KRaft), Kafka UI, Mailpit, order and notification ----
kafka:
image: apache/kafka:4.3.1
hostname: kafka
ports:
- "9094:9094"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1"]
interval: 10s
timeout: 10s
start_period: 60s # a busy laptop can take a minute to start the broker
retries: 20
kafka-init:
image: apache/kafka:4.3.1
depends_on:
kafka:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- |
/opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --create --if-not-exists \
--topic order-events --partitions 6 --replication-factor 1 &&
/opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --create --if-not-exists \
--topic order-events-dlt --partitions 6 --replication-factor 1
restart: "no"
kafka-ui:
image: kafbat/kafka-ui:v1.5.0
depends_on:
kafka:
condition: service_healthy
ports:
- "8089:8080"
environment:
KAFKA_CLUSTERS_0_NAME: boxoffice
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
mailpit:
image: axllent/mailpit:v1.31
ports:
- "8025:8025"
- "1025:1025"
order:
build: ./services/order
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: order
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
REDIS_HOST: redis
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
NOTIFICATION_URL: http://notification:8086
PAYMENT_URL: http://toxiproxy:18085 # lab 9: through Toxiproxy, not payment:8085
PAYMENT_RESILIENCE: ${PAYMENT_RESILIENCE:-true}
PAYMENT_READ_TIMEOUT: ${PAYMENT_READ_TIMEOUT:-1s}
PAYMENT_RETRY_ATTEMPTS: ${PAYMENT_RETRY_ATTEMPTS:-3}
PAYMENT_BULKHEAD: ${PAYMENT_BULKHEAD:-10}
NOTIFY_MODE: ${NOTIFY_MODE:-outbox} # outbox (after) | sync (before)
ADMIT_PER_SECOND: ${ADMIT_PER_SECOND:-20} # waiting-room admission rate
ports:
- "8084:8084"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
kafka-init:
condition: service_completed_successfully
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
notification:
build: ./services/notification
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: notification
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
MAIL_HOST: mailpit
NOTIFY_CONCURRENCY: ${NOTIFY_CONCURRENCY:-3} # consumer threads (max useful: 6 partitions)
EMAIL_LATENCY: ${EMAIL_LATENCY:-50ms} # simulated e-mail provider latency
NOTIFY_IDEMPOTENT: ${NOTIFY_IDEMPOTENT:-true} # false = no dedupe (to see duplicates)
ports:
- "8086:8086"
depends_on:
db:
condition: service_healthy
mailpit:
condition: service_started
kafka-init:
condition: service_completed_successfully
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8086/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
# ---- lab 8: observability backend (Grafana, Prometheus, Tempo, Loki, OTel Collector) ----
otel-lgtm:
image: grafana/otel-lgtm:0.37.0
ports:
- "3000:3000" # Grafana (no login)
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
volumes:
- ./infra/grafana/dashboards.yaml:/otel-lgtm/grafana/conf/provisioning/dashboards/boxoffice.yaml:ro
- ./infra/grafana/dashboards:/otel-lgtm/boxoffice-dashboards:ro
- ./infra/grafana/alerting:/otel-lgtm/grafana/conf/provisioning/alerting:ro
# ---- lab 9: payment adapter, fake provider and fault injection ----
psp:
image: wiremock/wiremock:3.13.2
volumes:
- ./infra/psp:/home/wiremock:ro
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.13.0
command: ["-host=0.0.0.0", "-config=/config/toxiproxy.json"]
volumes:
- ./infra/toxiproxy/toxiproxy.json:/config/toxiproxy.json:ro
ports:
- "8474:8474"
payment:
build: ./services/payment
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: payment
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
PSP_URL: http://toxiproxy:18095 # the provider, through Toxiproxy
ports:
- "8085:8085"
depends_on:
db:
condition: service_healthy
toxiproxy:
condition: service_started
psp:
condition: service_started
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8085/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
volumes:
db-data:
replica-data:
Suba os três contêineres novos; o --wait retorna quando o payment está saudável (o primeiro build leva alguns minutos, a inicialização cerca de 30 s). Depois liste os proxies e faça uma cobrança:
"payment",
"psp"
]curl -s -w '\n%{http_code}\n' -X POST localhost:8085/payments -H 'Content-Type: application/json' \ -H 'Idempotency-Key: test-1' -d '{"orderId":"5b0c2f9e-7d0a-4f43-9a55-1c1c2c8f8e10","amount":350.00}'{"id":"a8744598-4ef4-4d4d-85f2-105e5661233c","orderId":"5b0c2f9e-7d0a-4f43-9a55-1c1c2c8f8e10","amount":350.00,"status":"AUTHORIZED","providerRef":"ch_2zm5esh2yuqd","createdAt":"2026-10-18T18:44:51.939198Z"}
201
Você deve ver 201 e um corpo como {"id":"a8744598-…","orderId":"5b0c2f9e-…","amount":350.00,"status":"AUTHORIZED","providerRef":"ch_2zm5esh2yuqd",…}. Envie o mesmo curl de novo: 200 em vez de 201, o mesmo id e o mesmo providerRef.
O seu primeiro toxic
Adicione 3 segundos de latência a toda resposta do proxy psp, cronometre um pagamento e remova o toxic:
curl -s -X POST localhost:8474/proxies/psp/toxics \
-d '{"name":"slow","type":"latency","stream":"downstream","attributes":{"latency":3000}}'
time curl -s -X POST localhost:8085/payments -H 'Content-Type: application/json' \
-H 'Idempotency-Key: test-2' -d '{"orderId":"5b0c2f9e-7d0a-4f43-9a55-1c1c2c8f8e10","amount":350.00}'
curl -s -X DELETE localhost:8474/proxies/psp/toxics/slow
Você deve ver {"…","status":500,"error":"Internal Server Error","path":"/payments"} depois de real 0m2.2s: o timeout de leitura do PspClient (2 s) disparou antes de a resposta de 3 s chegar. Sem ele, a thread teria esperado os 3 s inteiros — ou para sempre.
| Toxic | Atributos | Simula |
|---|---|---|
latency | latency, jitter (ms) | uma dependência lenta, uma rede congestionada |
timeout | timeout (ms; 0 = nunca fecha) | um buraco negro: os pacotes entram, nada volta |
reset_peer | timeout (ms) | conexão resetada pelo outro lado (um crash, um load balancer matando conexões) |
bandwidth | rate (KB/s) | um cano estreito |
limit_data | bytes | uma conexão cortada no meio de uma resposta |
| (proxy desativado) | POST /proxies/<name> com {"enabled": false} | a dependência está fora do ar: conexão recusada |
Cada toxic também aceita uma toxicity entre 0 e 1: a probabilidade de que ele se aplique a uma nova conexão — perfeito para "30% das chamadas falham".