Produção: CORS, rate limiting, versionamento e OpenTelemetry

Produção: CORS, rate limiting, versionamento e OpenTelemetry

Antes de ir para o público, a API da Livraria aprende a se defender e a ser observada: CORS com a política certa, rate limiting por cliente com 429 e Retry-After, versionamento sem quebrar contratos, health checks para o orquestrador e OpenTelemetry com um rastro que atravessa HTTP, gRPC e a fila.
Linguagem C#

• • 21 min de leitura

Uma API que funciona no computador do desenvolvedor ainda não está pronta para o mundo. Este artigo fecha a Fase 6 com o que separa as duas coisas: quem pode chamá-la pelo navegador, quanto cada cliente pode pedir, como o contrato muda sem quebrar ninguém e como enxergar o que acontece dentro de cinco processos ao mesmo tempo.

CORS: o navegador pergunta antes

Os navegadores aplicam a política de mesma origem. Um script carregado de https://loja.livraria.exemplo não pode ler respostas de https://api.livraria.exemplo, porque a origem (esquema, host e porta) é diferente. Essa regra impede que um site malicioso aberto numa aba use os cookies de outra aba para ler seus dados bancários.

O CORS (Cross-Origin Resource Sharing) é o mecanismo pelo qual um servidor autoriza origens específicas a ler suas respostas. Para requisições "não simples" (com Authorization, com JSON no corpo, com PUT ou DELETE), o navegador primeiro envia uma requisição de verificação, o preflight:

OPTIONS /produtos/7 HTTP/1.1
Origin: https://loja.livraria.exemplo
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

Se o servidor responder com os cabeçalhos Access-Control-Allow-* adequados, o navegador envia a requisição real. Se não responder, o navegador bloqueia a chamada e o JavaScript recebe um erro genérico.

O ponto que mais confunde é que CORS não protege o servidor. Ele protege o usuário do navegador. curl, Postman, um script Python ou outro servidor ignoram o CORS por completo. Autenticação e autorização continuam sendo a proteção real, e o CORS só diz ao navegador quais sites podem usar a API em nome de quem está navegando.

A Livraria vai precisar de CORS quando o front-end Blazor WebAssembly da Fase 7 chamar a API direto do navegador. As origens vêm da configuração tipada do exercício 1 de "Configuração, logging e testes de integração":

var cors = builder.Configuration.GetSection("Cors").Get<OpcoesDeCors>()!;

builder.Services.AddCors(o => o.AddPolicy("Loja", p => p
    .WithOrigins(cors.Origens)
    .WithMethods("GET", "POST", "PUT", "DELETE")
    .WithHeaders("Authorization", "Content-Type", "If-Match", "If-None-Match", "Idempotency-Key")
    .WithExposedHeaders("ETag", "Location", "Retry-After")
    .AllowCredentials()
    .SetPreflightMaxAge(TimeSpan.FromMinutes(10))));

// ...
app.UseCors("Loja");

Cada linha tem um porquê:

  • WithExposedHeaders: por padrão o JavaScript só enxerga um punhado de cabeçalhos "seguros" da resposta. Sem essa linha, o front-end não consegue ler o ETag que a concorrência otimista exige nem o Location de um recurso criado.
  • AllowCredentials é necessário para o cliente JavaScript do SignalR, que envia credenciais na negociação. Essa opção é incompatível com AllowAnyOrigin. A especificação proíbe liberar credenciais para "qualquer origem", e o ASP.NET Core recusa a combinação. Liberar tudo com credenciais anularia a política de mesma origem.
  • SetPreflightMaxAge deixa o navegador guardar a resposta do preflight e evita um OPTIONS antes de cada chamada.
  • A ordem no pipeline: UseCors vem depois do roteamento e antes da autenticação, porque o preflight não traz token e seria recusado com 401.

Rate limiting: cada cliente tem uma cota

Um cliente com defeito num laço infinito, um robô raspando o catálogo ou um ataque de força bruta contra o login têm o mesmo efeito: consomem recursos que eram de todos. O ASP.NET Core traz desde o .NET 7 um middleware de limitação de taxa com quatro algoritmos:

Algoritmo Como funciona Bom para
Janela fixa N permissões por janela (100 por minuto), zeradas na virada simplicidade; permite rajadas na virada da janela
Janela deslizante a janela é dividida em segmentos que expiram aos poucos suavizar o efeito da virada
Balde de fichas (token bucket) fichas repostas a uma taxa constante; cada requisição gasta uma permitir rajadas curtas com média controlada
Concorrência no máximo N requisições ao mesmo tempo proteger operações caras

O que torna a limitação útil é a partição: cada cliente ganha seu próprio limitador, identificado pelo usuário autenticado ou pelo IP.

using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.OnRejected = (contexto, ct) =>
    {
        if (contexto.Lease.TryGetMetadata(MetadataName.RetryAfter, out var espera))
            contexto.HttpContext.Response.Headers.RetryAfter =
                ((int)Math.Ceiling(espera.TotalSeconds)).ToString();
        return ValueTask.CompletedTask;
    };

    o.AddPolicy("Leitura", ctx => RateLimitPartition.GetFixedWindowLimiter(
        IdentificarCliente(ctx),
        _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 300,
            Window = TimeSpan.FromMinutes(1)
        }));

    o.AddPolicy("Compra", ctx => RateLimitPartition.GetTokenBucketLimiter(
        IdentificarCliente(ctx),
        _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = 5,                              // rajada máxima
            TokensPerPeriod = 1,
            ReplenishmentPeriod = TimeSpan.FromSeconds(10),
            QueueLimit = 0                               // sem fila: recusa na hora
        }));
});

static string IdentificarCliente(HttpContext ctx) =>
    ctx.User.FindFirstValue(ClaimTypes.NameIdentifier) is { } usuario
        ? $"u:{usuario}"
        : $"ip:{ctx.Connection.RemoteIpAddress}";
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();          // depois da autenticação, para enxergar o usuário

produtos.MapGet("/", Listar).RequireRateLimiting("Leitura");
pedidos.MapPost("/", Registrar).RequireRateLimiting("Compra");

A resposta 429 sai sem corpo do limitador, e o UseStatusCodePages de "Validação, ProblemDetails e OpenAPI" a transforma em ProblemDetails. O cabeçalho Retry-After diz ao cliente educado quanto esperar, e o cliente HTTP com resiliência padrão do .NET respeita esse cabeçalho nas retentativas.

Três armadilhas:

O IP que você vê pode ser o do proxy. Atrás de nginx ou de um balanceador, RemoteIpAddress é o endereço do proxy, e todos os clientes do mundo caem na mesma partição: o primeiro abuso bloqueia a loja inteira. O middleware UseForwardedHeaders lê o IP original do cabeçalho X-Forwarded-For, mas só deve confiar nele quando a requisição vier de um proxy conhecido, configurado nas opções do middleware. Sem essa restrição, qualquer cliente forja o cabeçalho e escolhe a própria partição.

Cotas por réplica. Os limitadores vivem na memória de cada processo. Com três réplicas, a cota efetiva é até três vezes maior. Para uma API de livraria isso é aceitável, porque o objetivo é conter abusos, não cobrar por chamada. Quando a cota precisa ser exata, a limitação vai para um gateway (YARP, nginx, o API Management da nuvem) ou para uma implementação sobre Redis.

Partições sem limite de memória. Cada chave nova cria um limitador. Um atacante que troca de IP a cada requisição cria milhões de partições. O runtime descarta limitadores ociosos, mas vale limitar também na borda da rede.

Versionamento: mudar sem quebrar

O exercício 5 de "Validação, ProblemDetails e OpenAPI" estabeleceu a regra: na entrada, afrouxar é seguro e apertar quebra; na saída, acrescentar é seguro e remover ou renomear quebra. Versionar é o último recurso, reservado para mudanças que não cabem nessa regra. Cada versão viva é código a manter, testar e documentar.

Suponha que a Livraria passe a vender para Portugal e o preço deixe de ser um número para virar um objeto com moeda:

// v1
{ "id": 7, "titulo": "Dom Casmurro", "preco": 39.90 }

// v2
{ "id": 7, "titulo": "Dom Casmurro", "preco": { "valor": 39.90, "moeda": "BRL" } }

Isso quebra todo cliente que lê preco como número, e pede uma v2. Há quatro lugares onde a versão pode ir:

Onde Exemplo Prós e contras
Caminho /v2/produtos/7 visível, fácil de rotear, de cachear e de testar no navegador; a URL do recurso muda
Query string /produtos/7?api-version=2 simples, mas fácil de esquecer
Cabeçalho Api-Version: 2 URL estável, mas invisível em logs e links
Tipo de mídia Accept: application/vnd.livraria.v2+json o mais purista, e o mais trabalhoso para clientes

A maioria das APIs públicas usa o caminho, e a Livraria também. Para duas versões, dois grupos resolvem sem biblioteca nenhuma:

var v1 = app.MapGroup("/v1").WithGroupName("v1");
var v2 = app.MapGroup("/v2").WithGroupName("v2");

v1.MapearProdutosV1();       // devolve ProdutoResposta
v2.MapearProdutosV2();       // devolve ProdutoRespostaV2, com Dinheiro

builder.Services.AddOpenApi("v1");
builder.Services.AddOpenApi("v2");

Por padrão, cada documento OpenAPI inclui os endpoints cujo nome de grupo coincide com o nome do documento: /openapi/v1.json descreve só a v1. Para cenários com muitas versões, negociação e relatórios automáticos, a biblioteca Asp.Versioning (Asp.Versioning.Http), mantida pela comunidade e sucessora dos pacotes de versionamento da Microsoft, acrescenta conjuntos de versões, leitores de versão por caminho, query ou cabeçalho, e o cabeçalho api-supported-versions em todas as respostas.

As rotas atuais, sem prefixo, continuam funcionando como apelido da v1 até os clientes migrarem. Mover /produtos para /v1/produtos de uma vez seria, por si só, uma mudança incompatível.

Aposentar uma versão também é comunicação. As respostas da v1 passam a trazer o cabeçalho Sunset (RFC 8594), com a data em que ela deixa de existir, e um link para o guia de migração. Os logs mostram quem ainda usa a v1. Até a data, a v1 continua funcionando.

Health checks: o orquestrador pergunta se você está bem

Orquestradores como Kubernetes, Docker Swarm e os serviços de contêiner das nuvens precisam saber duas coisas diferentes sobre cada réplica:

  • Viva (liveness): o processo está funcionando? Se não estiver, deve ser reiniciado.
  • Pronta (readiness): consegue atender agora? Se não, deve sair do balanceador por um tempo, sem reiniciar.
builder.Services.AddHealthChecks()
    .AddDbContextCheck<LivrariaContext>("banco", tags: ["pronto"])
    .AddRedis(builder.Configuration.GetConnectionString("Redis")!, "redis", tags: ["pronto"]);

app.MapHealthChecks("/saude/viva", new() { Predicate = _ => false })
   .AllowAnonymous().DisableRateLimiting();

app.MapHealthChecks("/saude/pronta", new() { Predicate = c => c.Tags.Contains("pronto") })
   .AllowAnonymous().DisableRateLimiting();

A verificação de vida não consulta dependências (Predicate = _ => false só confirma que o processo responde). Se ela checasse o banco, uma queda do PostgreSQL faria o orquestrador reiniciar todas as réplicas em cascata, sem resolver nada, porque o problema não está nelas. A de prontidão checa o banco e o Redis e tira a réplica do balanceador enquanto eles não respondem. O AddDbContextCheck vem do pacote Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore. O AddRedis vem da coleção comunitária AspNetCore.HealthChecks.*, que cobre também RabbitMQ, MongoDB e dezenas de outros serviços. O AllowAnonymous resolve o problema previsto no exercício 4 de "Autenticação e autorização com JWT".

Observabilidade: três sinais

Uma cotação de frete demorou 4 segundos. Onde o tempo foi gasto? Na API, no serviço de frete, numa transportadora, no Redis? Com os logs de "Configuração, logging e testes de integração" dá para ver o que cada processo registrou, mas não a história de uma requisição atravessando todos eles. Observabilidade é a capacidade de responder perguntas que você não previu, e ela se apoia em três sinais:

  • Rastros (traces): a árvore de operações de uma requisição, com início, duração e relações de causa. Cada nó é um span.
  • Métricas: números agregados ao longo do tempo, como requisições por segundo, latência p95, pedidos por minuto e uso de memória. São baratas de guardar e boas para alertas.
  • Logs: os eventos estruturados que já temos, agora correlacionados com o rastro em que aconteceram.

O OpenTelemetry (OTel) é o padrão aberto da CNCF para produzir e transportar esses três sinais, independente de fornecedor. A aplicação emite pelo protocolo OTLP, e o destino pode ser Jaeger, Prometheus, Grafana (Tempo, Loki, Mimir), Elastic, Datadog, Azure Monitor ou qualquer outro que fale OTLP. Trocar de fornecedor é trocar um endereço, não reescrever a instrumentação.

O .NET tem uma vantagem aqui. As APIs de instrumentação fazem parte da própria biblioteca padrão: System.Diagnostics.Activity (um span), ActivitySource, System.Diagnostics.Metrics.Meter e ILogger. O ASP.NET Core, o HttpClient, o gRPC, o Npgsql e outras bibliotecas já emitem esses sinais. O SDK do OpenTelemetry apenas escuta e exporta.

Ligando o OpenTelemetry

dotnet add src/Livraria.Api package OpenTelemetry.Extensions.Hosting
dotnet add src/Livraria.Api package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add src/Livraria.Api package OpenTelemetry.Instrumentation.AspNetCore
dotnet add src/Livraria.Api package OpenTelemetry.Instrumentation.Http
dotnet add src/Livraria.Api package OpenTelemetry.Instrumentation.Runtime
dotnet add src/Livraria.Api package Npgsql.OpenTelemetry
builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("livraria-api"))
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()      // inclui as chamadas gRPC ao frete
        .AddNpgsql()
        .AddSource(Telemetria.NomeDaFonte))
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()         // GC, threads, exceções
        .AddMeter(Telemetria.NomeDoMedidor))
    .WithLogging()
    .UseOtlpExporter();

UseOtlpExporter lê o destino da variável OTEL_EXPORTER_OTLP_ENDPOINT, seguindo a convenção do OpenTelemetry. O código não conhece o fornecedor. O mesmo bloco vai para o Livraria.Frete e para o Livraria.Worker, cada um com seu nome de serviço, e é esse nome que diferencia os processos no rastro.

Para ver tudo localmente, o painel do .NET Aspire roda como um contêiner avulso e recebe OTLP:

docker run --rm -d --name painel -p 18888:18888 -p 4317:18889 \
  mcr.microsoft.com/dotnet/aspire-dashboard:latest

Com OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 nos três serviços, http://localhost:18888 mostra rastros, métricas e logs estruturados lado a lado, e o link de acesso aparece no log do contêiner. Uma cotação lenta aparece como uma árvore: o span da requisição HTTP na API, dentro dele a chamada gRPC, dentro dela o span do serviço de frete com as consultas às transportadoras. A barra mais longa mostra de imediato onde estão os 4 segundos.

Como o contexto viaja? Pelo cabeçalho traceparent do padrão W3C Trace Context, que o HttpClient injeta e o ASP.NET Core lê automaticamente. Todo span de uma mesma requisição compartilha o mesmo trace id. O traceId que colocamos nos ProblemDetails de "Validação, ProblemDetails e OpenAPI" vinha de Activity.Current?.Id: agora ele é o identificador de um rastro distribuído real, e o cliente que reclama de um erro entrega exatamente o valor que abre o rastro no painel.

Telemetria da própria aplicação

As instrumentações automáticas mostram HTTP, SQL e gRPC. O que interessa ao negócio precisa ser acrescentado:

public static class Telemetria
{
    public const string NomeDaFonte = "Livraria.Vendas";
    public const string NomeDoMedidor = "Livraria.Vendas";

    public static readonly ActivitySource Fonte = new(NomeDaFonte);
    private static readonly Meter Medidor = new(NomeDoMedidor);

    public static readonly Counter<long> PedidosRegistrados =
        Medidor.CreateCounter<long>("livraria.pedidos.registrados", unit: "{pedido}");

    public static readonly Counter<long> ConflitosDeEstoque =
        Medidor.CreateCounter<long>("livraria.vendas.conflitos", unit: "{conflito}");

    public static readonly Histogram<double> ValorDoPedido =
        Medidor.CreateHistogram<double>("livraria.pedido.valor", unit: "BRL");
}
// no ServicoDeVendas.RegistrarAsync
using var atividade = Telemetria.Fonte.StartActivity("RegistrarPedido");
atividade?.SetTag("livraria.pedido.itens", itens.Count);

// ... após o commit:
atividade?.SetTag("livraria.pedido.id", pedido.Numero);
Telemetria.PedidosRegistrados.Add(1);
Telemetria.ValorDoPedido.Record((double)pedido.Total);

StartActivity devolve null quando ninguém está ouvindo a fonte, e por isso o ?.. Com a telemetria desligada, o custo é praticamente zero. Os projetos Livraria.Aplicacao e Livraria.Dominio continuam sem nenhuma dependência do OpenTelemetry: usam só System.Diagnostics, que faz parte da BCL.

Métricas têm uma regra que, se violada, custa caro literalmente: tags de métricas precisam ter poucos valores possíveis (baixa cardinalidade). transportadora com 5 valores é ótimo. clienteId com um milhão de valores cria um milhão de séries temporais no backend, e a conta do fornecedor chega com o susto. Detalhes por cliente vão nos rastros e logs, que são por evento. Métricas são agregados.

O rastro através da fila

O HTTP e o gRPC propagam o contexto sozinhos. A fila de "Worker Services e mensageria com RabbitMQ" tem um obstáculo a mais: o outbox. A mensagem é publicada pelo retransmissor, segundos depois e fora da requisição original. Sem cuidado, o rastro da venda termina no commit, e o processamento no worker aparece como um rastro órfão.

A solução é guardar o contexto junto com a mensagem e restaurá-lo em cada salto:

// 1. Ao gravar na caixa de saída: guarda o traceparent atual
var mensagem = TradutorDeEventos.ParaMensagem(evento);
mensagem.ContextoDeRastreamento = Activity.Current?.Id;
CaixaDeSaida.Add(mensagem);
// 2. No retransmissor: abre um span "filho" do contexto original e o injeta na mensagem
var pai = ActivityContext.TryParse(mensagem.ContextoDeRastreamento, null, out var ctx) ? ctx : default;
using var atividade = Telemetria.Fonte.StartActivity(
    $"{mensagem.Tipo} publicar", ActivityKind.Producer, pai);

Propagators.DefaultTextMapPropagator.Inject(
    new PropagationContext(atividade?.Context ?? default, Baggage.Current),
    propriedades,
    (p, chave, valor) => (p.Headers ??= new Dictionary<string, object?>())[chave] = valor);
// 3. No consumidor: extrai o contexto dos cabeçalhos e continua o rastro
var contexto = Propagators.DefaultTextMapPropagator.Extract(default, entrega.BasicProperties,
    (p, chave) => p.Headers?.TryGetValue(chave, out var v) == true && v is byte[] bytes
        ? [Encoding.UTF8.GetString(bytes)]
        : Array.Empty<string>());

using var atividade = Telemetria.Fonte.StartActivity(
    "pedido.registrado processar", ActivityKind.Consumer, contexto.ActivityContext);

MensagemDeSaida ganhou a propriedade ContextoDeRastreamento, com { get; set; }: como ela é uma classe e não um record, o with não serve (seria o erro CS8858), e a propriedade é preenchida depois de criar a mensagem. Ela também virou uma coluna, portanto uma migration por provedor. Os Propagators vêm do pacote OpenTelemetry.Api, já trazido pelo SDK. O RabbitMQ entrega os valores de cabeçalho como byte[], daí a conversão no extrator. Depois disso, o rastro de uma venda mostra, numa única árvore, a requisição HTTP, o SQL do commit, a publicação feita pelo retransmissor segundos depois e o processamento no worker de ranking, cada um no seu serviço.

Amostragem

Registrar 100% dos rastros em produção é caro e raramente necessário. Um amostrador decide na raiz do rastro se ele será gravado, e todos os serviços respeitam a decisão, propagada no próprio traceparent:

.WithTracing(t => t.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1))))

10% das requisições são suficientes para enxergar padrões de latência. Métricas não são amostradas: contam tudo, por serem agregadas. Para não perder justamente os rastros com erro, os coletores do OpenTelemetry oferecem tail sampling, que decide depois que o rastro termina e mantém todos os que falharam.

O pipeline completo da API

app.UseForwardedHeaders();          // IP e esquema reais atrás do proxy
app.UseExceptionHandler();
app.UseStatusCodePages();
if (!app.Environment.IsDevelopment()) app.UseHsts();
app.UseHttpsRedirection();
app.UseCors("Loja");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseOutputCache();
app.UseMiddleware<IdDeCorrelacao>();

app.MapHealthChecks(...);
app.MapHub<HubDaLivraria>("/hubs/livraria").AllowAnonymous();
app.MapearProdutos();               // apelido da v1
app.MapGroup("/v1").MapearProdutosV1();
app.MapGroup("/v2").MapearProdutosV2();
app.MapearPedidos();
app.MapearFretes();

Cada linha foi justificada num dos dez artigos desta fase, e a ordem segue o princípio de "ASP.NET Core por dentro": quem precisa envolver tudo vem primeiro, quem precisa saber quem é o usuário vem depois da autenticação, e os endpoints vêm por último.

Com isso a Fase 6 está completa. Começamos com quatro linhas que respondiam "Livraria no ar" e terminamos com um sistema de verdade: uma API com contrato validado e documentado, segura por padrão e testada contra bancos reais, com cache em três níveis, avisos em tempo real, trabalho assíncrono entregue com garantias, um serviço interno que conversa por gRPC, e agora cotas, versões, checagens de saúde e um rastro que atravessa todos os processos. A Livraria ainda só existe para quem sabe fazer requisições HTTP. A Fase 7 dá a ela um rosto: um site para o público, começando pelo modelo mais direto de todos, em que o servidor monta o HTML completo e o navegador só o exibe.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega configurou AllowAnyOrigin() "porque a API já exige JWT, então não tem risco". Ele está certo? Em que cenário da Livraria a resposta mudaria?

Ver resposta

✓ Resposta: Neste cenário específico ele está quase certo. Com JWT no cabeçalho Authorization, um site malicioso não tem o token do usuário: o navegador não o anexa sozinho, ao contrário de um cookie. Liberar qualquer origem só permite que outros sites leiam dados públicos, que já são públicos. A resposta muda assim que houver autenticação por cookie, como na área administrativa da Fase 7 ou no BFF: aí o navegador anexa o cookie automaticamente, e CORS permissivo com credenciais deixaria qualquer site agir em nome do usuário logado. É justamente por isso que a especificação proíbe AllowAnyOrigin com AllowCredentials. A boa prática é listar origens explicitamente de qualquer forma: é uma linha de configuração, e evita que uma mudança futura no esquema de autenticação abra uma brecha sem ninguém perceber.

Exercício 2

A política "Compra" usa balde de fichas com TokenLimit = 5, uma ficha a cada 10 s e QueueLimit = 0. Um cliente faz 8 pedidos em 2 segundos e depois mais um aos 25 segundos. Quais recebem 201 (ou 409) e quais recebem 429?

Ver resposta

✓ Resposta: O balde começa cheio, com 5 fichas. Os 5 primeiros pedidos consomem as fichas e passam. Do 6º ao 8º o balde está vazio e, sem fila, recebem 429 com Retry-After, porque a próxima ficha chega aos 10 s. Aos 10 s e aos 20 s, uma ficha é reposta de cada vez, e o balde acumula até 2 fichas. O pedido dos 25 s encontra 2 fichas, consome uma e passa. O balde de fichas aceita uma rajada inicial razoável, como um cliente que compra cinco livros em sequência, e impõe uma média de um pedido a cada 10 segundos ao longo do tempo.

Exercício 3

A partir da API atual, liste o que precisa acontecer, e em que ordem, para aposentar a v1 sem quebrar ninguém de surpresa.

Ver resposta

✓ Resposta: (1) Publicar a v2 ao lado da v1, com documentação e guia de migração. (2) Acrescentar nas respostas da v1 os cabeçalhos de aviso: Deprecation, Sunset com a data escolhida e Link para o guia. (3) Medir o uso: uma métrica com a tag versao (baixa cardinalidade) e logs por cliente mostram quem ainda chama a v1. (4) Contatar ativamente os clientes identificados. (5) Perto da data, opcionalmente, fazer interrupções programadas curtas (brownouts) para revelar clientes esquecidos. (6) Na data, remover a v1, que passa a responder 410 Gone com ProblemDetails apontando para a v2, e manter as rotas sem prefixo apontando para a nova versão só se isso tiver sido anunciado. A ordem importa: sem a etapa de medição, a data de remoção é um palpite.

Exercício 4

O /saude/pronta verifica PostgreSQL e Redis. Numa madrugada o Redis cai, e todas as réplicas da API saem do balanceador: a loja inteira fica fora do ar. O que está errado no desenho, considerando o que "Cache com Redis, HybridCache e output caching" disse sobre o Redis?

Ver resposta

✓ Resposta: A checagem de prontidão tratou o Redis como dependência obrigatória, mas o próprio desenho da Livraria o trata como otimização: o HybridCache cai para memória e banco, o output cache deixa de ser usado e o SignalR perde o backplane mas continua local. Tirar as réplicas do ar por causa do Redis transformou uma degradação em indisponibilidade total. A prontidão deve checar só o que impede de fato o atendimento, que aqui é o banco principal. O Redis pode ter um check próprio, com resultado Degraded em vez de Unhealthy, exposto num endpoint de diagnóstico e ligado a um alerta, sem participar da decisão do balanceador. A exceção é o armazenamento de idempotência, se estiver no Redis: a decisão tomada no exercício 5 de "Cache com Redis, HybridCache e output caching" (recusar pedidos com 503) é uma degradação da operação de compra, e não um motivo para tirar a loja inteira do ar.

Exercício 5

Um desenvolvedor acrescentou a tag livraria.cliente.id ao contador livraria.pedidos.registrados para "ver os pedidos por cliente". Explique o problema e mostre onde essa informação deveria ir.

Ver resposta

✓ Resposta: Cada combinação distinta de valores de tags é uma série temporal separada no backend de métricas. Com dezenas de milhares de clientes, o contador vira dezenas de milhares de séries, que ocupam memória no processo (o SDK agrega por combinação), custam armazenamento e dinheiro no fornecedor e deixam as consultas lentas. Muitos backends impõem limites e passam a descartar dados. Pedidos por cliente é uma pergunta sobre eventos individuais. Ela é respondida por uma tag no span (atividade?.SetTag("enduser.id", clienteId), respeitando a política de dados pessoais), pelo log estruturado PedidoRegistrado com o campo ClienteId, e em última instância por uma consulta ao banco, que é a fonte da verdade. Na métrica ficam só dimensões de baixa cardinalidade, como canal (site, app, jogo) ou forma_de_pagamento.

Comentários

Mais em Linguagem C#

Arquitetura de jogo e por que não alocar dentro do loop
Arquitetura de jogo e por que não alocar dentro do loop

A Biblioteca em Apuros ganha arquitetura: máquinas de estados para a…

Tempo real com SignalR: quando o servidor toma a iniciativa
Tempo real com SignalR: quando o servidor toma a iniciativa

A Livraria passa a avisar quem está conectado: polling, SSE e WebSockets, hubs…

Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando
Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando

Medir antes de otimizar com BenchmarkDotNet e as ferramentas de diagnóstico do…