O site já conversa com a API em vários pontos, cada um do seu jeito. Neste artigo essa conversa ganha regras: clientes HTTP com retentativas e prazos, erros traduzidos, cache que pergunta antes de baixar e um proxy reverso que dá ao navegador um único endereço e guarda as credenciais no servidor.
O que temos até aqui
Levantando o código dos últimos quatro artigos, o site chama a API por quatro caminhos diferentes:
| Quem | Como | Problema |
|---|---|---|
| Razor Pages (vitrine, detalhe) | CatalogoViaApi com HttpClient tipado |
sem retentativa, sem prazo, sem cache |
| Formulários (frete, avise-me) | ICotadorDeFrete, RegistrarAvisoAsync |
cada um traduz ProblemDetails do seu jeito |
| Carrinho em WebAssembly | endpoints /bff/* feitos à mão |
um endpoint novo para cada operação |
| Tempo real | o navegador fala com o hub da API | exige CORS e um endereço de API exposto ao público |
Nenhum desses caminhos está errado, mas juntos são quatro formas de fazer a mesma coisa. Quando a API ficar lenta, e ela vai ficar, cada um vai falhar de um jeito diferente.
Uma regra organiza o artigo: quem chama um serviço remoto é responsável por decidir quanto esperar, quantas vezes tentar e o que mostrar quando não der certo. A API da Fase 6 já fez a parte dela, com contrato, ProblemDetails, ETag e rate limiting. Agora é a vez do cliente.
Um contrato, duas formas de segui-lo
O site usa os DTOs do projeto Livraria.Contratos, compartilhado com a API. É a forma mais simples de manter os dois lados alinhados, porque um campo renomeado quebra a compilação de ambos. O custo é o acoplamento: o site precisa ser recompilado quando o contrato muda, e só clientes .NET se beneficiam.
A alternativa é gerar o cliente a partir do documento OpenAPI que a API já produz em tempo de build desde "Validação, ProblemDetails e OpenAPI". O Kiota, gerador de clientes da Microsoft, produz um cliente fluente e tipado em C#, TypeScript, Java, Go, Python e outras linguagens:
dotnet tool install --global Microsoft.OpenApi.Kiota
kiota generate -l CSharp -d ../Livraria.Api/Livraria.Api.json \
-c ClienteDaApi -n Livraria.Site.ApiGerada -o ./ApiGerada
var produto = await cliente.V1.Produtos[7].GetAsync(cancellationToken: ct);
Com geração, o contrato de verdade passa a ser o documento, que a verificação de compatibilidade do CI já protege. O aplicativo móvel e o jogo do capstone podem gerar seus próprios clientes da mesma fonte. A Livraria continua com os contratos compartilhados, porque todos os consumidores atuais são .NET, mas vale ter as duas opções em mente: quando surgir o primeiro cliente em outra linguagem, a geração passa a compensar.
Resiliência: esperar pouco, tentar de novo com critério
Chamadas pela rede falham de três formas: rápido (conexão recusada, 503), devagar (a API está sobrecarregada e demora 30 segundos para responder) ou de vez em quando (um pacote perdido, uma réplica reiniciando). A falha lenta é a mais perigosa. Cada requisição do site que espera a API ocupa uma thread de trabalho, memória e uma conexão. Se a API passa a responder em 30 segundos, o site acumula centenas de requisições paradas e cai também, arrastado. É a falha em cascata.
O pacote Microsoft.Extensions.Http.Resilience, construído sobre o Polly e já usado no cliente gRPC de "gRPC: contratos binários e streaming entre serviços", aplica uma estratégia padrão a um HttpClient:
builder.Services.AddHttpClient<ICatalogoDaLoja, CatalogoViaApi>(c =>
c.BaseAddress = new Uri(builder.Configuration["Servicos:Api"]!))
.AddStandardResilienceHandler(o =>
{
o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(6);
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
o.Retry.MaxRetryAttempts = 2;
o.Retry.DisableForUnsafeHttpMethods(); // POST, PUT, DELETE não são repetidos
o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
});
A estratégia padrão empilha cinco camadas, de fora para dentro:
- Limitador de concorrência: um teto de chamadas simultâneas a esse serviço.
- Timeout total: o prazo máximo de toda a operação, com retentativas incluídas. Aqui, 6 segundos.
- Retentativa com espera exponencial e jitter (variação aleatória, para que mil clientes não tentem de novo no mesmo milissegundo), só para erros transitórios: falhas de rede, 5xx, 408 e 429. Respeita o
Retry-Afterdo rate limiting de "Produção". - Circuit breaker: se a proporção de falhas numa janela passa de um limite, o circuito abre e as chamadas falham imediatamente, sem ir à rede, por um tempo. Depois deixa algumas passarem para testar. Isso dá à API espaço para se recuperar, em vez de ser martelada enquanto está caída, e faz o site falhar em milissegundos em vez de esperar.
- Timeout por tentativa: nenhuma tentativa individual espera mais de 2 segundos.
DisableForUnsafeHttpMethods merece destaque. Repetir um GET é inofensivo. Repetir um POST /pedidos cujo primeiro envio pode ter chegado, com a resposta perdida no caminho, é o pedido duplicado do exercício 5 de "A API da Livraria". Um POST só pode ser repetido com segurança quando carrega uma Idempotency-Key, e nesse caso a retentativa é habilitada explicitamente para aquele cliente.
Os números de prazo seguem um orçamento que vai de fora para dentro. O usuário tolera talvez 8 segundos por uma página. O site reserva 6 para a API. A API tem 2 segundos para o frete, com o prazo propagado por gRPC. Prazos internos maiores que os externos não têm sentido: o trabalho continuaria depois de quem pediu já ter desistido.
Erros: um formato, uma tradução
Hoje cada cliente do site lê as respostas de erro à sua maneira. Como a API sempre responde erros em ProblemDetails, o site pode ter uma única tradução:
public abstract record Resultado<T>
{
public sealed record Ok(T Valor) : Resultado<T>;
public sealed record NaoEncontrado : Resultado<T>;
public sealed record Invalido(IDictionary<string, string[]> Erros) : Resultado<T>;
public sealed record Conflito(string Mensagem) : Resultado<T>;
public sealed record Indisponivel(string? TraceId) : Resultado<T>;
}
public static class RespostasDaApi
{
public static async Task<Resultado<T>> LerAsync<T>(
this HttpResponseMessage resposta, ILogger log, CancellationToken ct)
{
if (resposta.IsSuccessStatusCode)
return new Resultado<T>.Ok((await resposta.Content.ReadFromJsonAsync<T>(ct))!);
var problema = resposta.Content.Headers.ContentType?.MediaType == "application/problem+json"
? await resposta.Content.ReadFromJsonAsync<HttpValidationProblemDetails>(ct)
: null;
var traceId = problema?.Extensions.TryGetValue("traceId", out var t) == true ? t?.ToString() : null;
switch ((int)resposta.StatusCode)
{
case 404: return new Resultado<T>.NaoEncontrado();
case 400 when problema?.Errors.Count > 0: return new Resultado<T>.Invalido(problema.Errors);
case 409 or 412 or 422: return new Resultado<T>.Conflito(problema?.Detail ?? problema?.Title ?? "Conflito.");
}
log.LogWarning("API respondeu {Status} ({Titulo}); traceId {TraceId}",
(int)resposta.StatusCode, problema?.Title, traceId);
return new Resultado<T>.Indisponivel(traceId);
}
}
Cada cliente fica com três linhas: montar a requisição, enviar e LerAsync. As páginas fazem switch sobre o Resultado, como o DetalheModel já fazia com a cotação. O traceId que a API colocou no ProblemDetails desde "Validação, ProblemDetails e OpenAPI" entra no log do site, e com o OpenTelemetry de "Produção" o rastro já liga os dois processos. O log do site, a página de erro amigável ("se o problema continuar, informe o código X") e o rastro distribuído passam a falar do mesmo identificador.
Exceções ficam reservadas para o que é realmente excepcional: TimeoutRejectedException e BrokenCircuitException da resiliência, e HttpRequestException sem resposta. Um try/catch no mesmo LerAsync, ou num DelegatingHandler, também as transforma em Indisponivel, e as páginas lidam com um único caminho de falha.
Cache condicional do lado do cliente
A API devolve ETag e Cache-Control: no-cache para produtos desde "Cache com Redis, HybridCache e output caching". O site ignorava esses cabeçalhos e baixava o corpo inteiro a cada exibição de página. Um DelegatingHandler, um middleware do lado do HttpClient, faz o site se comportar como um navegador bem-educado:
public sealed class CacheCondicional(IMemoryCache memoria) : DelegatingHandler
{
private sealed record Guardado(string ETag, byte[] Corpo, string TipoDeConteudo);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage requisicao, CancellationToken ct)
{
if (requisicao.Method != HttpMethod.Get)
return await base.SendAsync(requisicao, ct);
var chave = $"http:{requisicao.RequestUri}";
memoria.TryGetValue(chave, out Guardado? guardado);
if (guardado is not null)
requisicao.Headers.IfNoneMatch.Add(EntityTagHeaderValue.Parse(guardado.ETag));
var resposta = await base.SendAsync(requisicao, ct);
if (resposta.StatusCode == HttpStatusCode.NotModified && guardado is not null)
{
resposta.Dispose();
return Reconstruir(requisicao, guardado);
}
if (resposta.IsSuccessStatusCode && resposta.Headers.ETag is { } etag)
{
var corpo = await resposta.Content.ReadAsByteArrayAsync(ct);
var tipo = resposta.Content.Headers.ContentType?.ToString() ?? "application/json";
memoria.Set(chave, new Guardado(etag.ToString(), corpo, tipo),
new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30), Size = corpo.Length });
resposta.Content = new ByteArrayContent(corpo);
resposta.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(tipo);
}
return resposta;
}
private static HttpResponseMessage Reconstruir(HttpRequestMessage requisicao, Guardado g)
{
var conteudo = new ByteArrayContent(g.Corpo);
conteudo.Headers.ContentType = MediaTypeHeaderValue.Parse(g.TipoDeConteudo);
return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = requisicao, Content = conteudo };
}
}
builder.Services.AddMemoryCache(o => o.SizeLimit = 50 * 1024 * 1024); // 50 MB
builder.Services.AddTransient<CacheCondicional>();
builder.Services.AddHttpClient<ICatalogoDaLoja, CatalogoViaApi>(...)
.AddHttpMessageHandler<CacheCondicional>()
.AddStandardResilienceHandler(...);
O resultado combina dois benefícios. Sempre atualizado, porque cada exibição pergunta à API "mudou?", e a resposta nunca é velha. Barato, porque quando nada mudou a resposta é um 304 sem corpo, a API responde com o DTO que já está no HybridCache, e o site não desserializa nada novo. Para a lista de mais vendidos do cabeçalho, que não precisa ser atualíssima, um HybridCache com expiração de um minuto no próprio site é ainda mais barato. A tabela de "quanto atraso cada dado tolera" de "Cache com Redis, HybridCache e output caching" vale também do lado do cliente.
A ordem dos handlers importa. Handlers acrescentados primeiro ficam mais por fora. Com o cache por fora da resiliência, uma resposta em cache não passa pelo circuit breaker, e uma retentativa não consulta o cache de novo a cada tentativa. O SizeLimit com Size por entrada impede que o cache cresça sem limite. Sem isso, um robô que percorre todos os produtos encheria a memória do site.
Um BFF de verdade com YARP
O carrinho em WebAssembly fala com o site por endpoints /bff escritos à mão. O hub de tempo real exigia que o navegador conhecesse o endereço da API e que a API liberasse CORS para o site. Com a área administrativa e o login chegando, o navegador também precisaria de tokens. O padrão Backend for Frontend resolve as três coisas de uma vez: o navegador fala só com o site, na mesma origem, e o site encaminha para a API o que for preciso, acrescentando credenciais do lado do servidor.
O YARP (Yet Another Reverse Proxy) é o proxy reverso da Microsoft, uma biblioteca que transforma uma aplicação ASP.NET Core num proxy configurável. É o mesmo componente que roda na frente de serviços internos da própria Microsoft.
dotnet add src/Livraria.Site package Yarp.ReverseProxy
"ReverseProxy": {
"Routes": {
"api": {
"ClusterId": "livraria-api",
"Match": { "Path": "/api/{**resto}" },
"Transforms": [
{ "PathRemovePrefix": "/api" },
{ "RequestHeaderRemove": "Cookie" }
]
}
},
"Clusters": {
"livraria-api": {
"Destinations": { "principal": { "Address": "https://api.livraria.interno/" } }
}
}
}
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(contexto =>
{
contexto.AddRequestTransform(async transformacao =>
{
var token = await transformacao.HttpContext.GetTokenAsync("access_token");
if (token is not null)
transformacao.ProxyRequest.Headers.Authorization = new("Bearer", token);
});
});
// ...
app.UseMiddleware<ExigeCabecalhoAntiCsrf>();
app.MapReverseProxy();
Uma requisição do navegador para /api/v1/produtos/7 sai do site como GET https://api.livraria.interno/v1/produtos/7:
PathRemovePrefixtira o/api, e a API não sabe que existe um proxy na frente.RequestHeaderRemove: Cookieimpede que os cookies do site (sessão, carrinho) vazem para a API, que não precisa deles.- O transform de autorização lê o access token guardado na sessão do usuário do lado do servidor e o anexa como
Bearer. O navegador nunca vê o token, que fica fora do alcance de qualquer XSS. É o desenho recomendado em "Autenticação e autorização com JWT". Enquanto ninguém está logado,GetTokenAsyncdevolve nulo e a requisição segue anônima. O login que preenche esse token é assunto do próximo artigo. - WebSockets passam pelo YARP sem configuração extra. O hub fica acessível em
/api/hubs/livraria, na mesma origem do site. Para o navegador, o CORS da API deixa de ser necessário. Ele continua configurado para outros clientes que chamem a API diretamente. - O YARP propaga
traceparente acrescenta os cabeçalhosX-Forwarded-*, e o rastro distribuído continua atravessando o proxy. Por isso a API deve confiar nesses cabeçalhos só vindos do endereço do site, como em "Produção".
O proxy também tem balanceamento entre destinos, verificações de saúde ativas e passivas e as mesmas políticas de rate limiting, autorização e output cache do ASP.NET Core por rota, porque é, afinal, uma aplicação ASP.NET Core.
CSRF no BFF
Com o token agora anexado automaticamente pelo servidor a partir de um cookie de sessão, o risco de CSRF de "Formulários e validação" volta: um site malicioso poderia fazer o navegador da vítima chamar /api/v1/pedidos, e o proxy anexaria o token. Formulários HTML de outros sites só conseguem enviar tipos de conteúdo simples, e o SameSite=Lax já barra a maior parte. A defesa adicional padrão em BFFs é exigir um cabeçalho personalizado. Um site de outra origem não consegue acrescentá-lo sem passar por um preflight CORS, que o site recusa:
public sealed class ExigeCabecalhoAntiCsrf(RequestDelegate proximo)
{
public Task InvokeAsync(HttpContext contexto)
{
var caminho = contexto.Request.Path;
var protegido = (caminho.StartsWithSegments("/api") && !caminho.StartsWithSegments("/api/hubs"))
|| caminho.StartsWithSegments("/bff");
var seguro = HttpMethods.IsGet(contexto.Request.Method) || HttpMethods.IsHead(contexto.Request.Method);
if (protegido && !seguro && contexto.Request.Headers["X-CSRF"] != "1")
{
contexto.Response.StatusCode = StatusCodes.Status400BadRequest;
return Task.CompletedTask;
}
return proximo(contexto);
}
}
No projeto cliente, o HttpClient passa a enviar o cabeçalho por padrão: http.DefaultRequestHeaders.Add("X-CSRF", "1"). O hub fica de fora da exigência porque o cliente do SignalR abre a conexão com um POST .../negotiate que não carrega esse cabeçalho. O negotiate não altera nada, e a conexão WebSocket que vem depois é um GET.
Os endpoints /bff encolhem
Com o proxy, o carrinho em WebAssembly pode buscar o catálogo e cotar frete direto pela API, em /api/v1/..., e o CotadorViaBff feito à mão desaparece. Os endpoints /bff/carrinho continuam, e por um bom motivo: o carrinho é estado do site, guardado no Redis do site, e não um recurso da API. O proxy encaminha o que é da API. O BFF implementa o que é do front-end. Com essa distinção, cada endpoint tem um dono claro.
Testando o site contra a API real
A WebApplicationFactory de "Configuração, logging e testes de integração" permite um teste que sobe os dois processos em memória e liga um ao outro sem rede:
public sealed class FabricaDoSite(FabricaDaApi api) : WebApplicationFactory<Livraria.Site.Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Servicos:Api", api.Server.BaseAddress.ToString());
builder.ConfigureTestServices(servicos =>
{
servicos.AddHttpClient<ICatalogoDaLoja, CatalogoViaApi>()
.ConfigurePrimaryHttpMessageHandler(() => api.Server.CreateHandler());
});
}
}
O HttpClient do site passa a usar o handler do TestServer da API como transporte. O teste faz GET /livros/1 no site, e a requisição atravessa o site, o cliente tipado com cache e resiliência, a API em memória e o PostgreSQL no contêiner. O teste afirma que o HTML contém o título. É o teste mais próximo do que um usuário faz, e roda em segundos.
A camada de resiliência também merece testes próprios, com um handler falso que devolve 503 duas vezes e 200 na terceira, ou que demora 5 segundos. Esses testes são a única forma de saber, antes de produção, se o timeout de fato corta a espera e se o circuit breaker de fato abre.
Agora o site conversa com a API de um jeito só: clientes tipados que desistem a tempo, tentam de novo só o que pode ser repetido e param de insistir quando a API está caída; erros que chegam como um tipo que as páginas sabem exibir, com um identificador que leva ao rastro; um cache que nunca serve dado velho e quase nunca baixa dado repetido; e um proxy que dá ao navegador uma única origem e mantém as credenciais no servidor. O transform do proxy está pronto para anexar um token que ainda não existe, porque ninguém faz login no site.
Fontes e leituras recomendadas
- Microsoft Learn — Build resilient HTTP apps: Key development patterns (
Microsoft.Extensions.Http.Resilience): https://learn.microsoft.com/dotnet/core/resilience/http-resilience - Microsoft Learn — Make HTTP requests using IHttpClientFactory in ASP.NET Core (clientes tipados e
DelegatingHandler): https://learn.microsoft.com/aspnet/core/fundamentals/http-requests - YARP — documentação oficial: https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/yarp-overview
- Microsoft Learn — Kiota overview: https://learn.microsoft.com/openapi/kiota/overview
- Michael T. Nygard — Release It!, 2ª edição (Pragmatic Bookshelf, 2018), capítulos sobre timeouts, circuit breakers e falhas em cascata.
- IETF — OAuth 2.0 for Browser-Based Applications (draft, seção sobre o padrão BFF): https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
Exercícios
Exercício 1
Um colega configurou AttemptTimeout de 10 s, TotalRequestTimeout de 6 s e 3 retentativas. Descreva o comportamento real quando a API está travada, e corrija a configuração.
Ver resposta
✓ Resposta: Ela nem chega a rodar. A estratégia padrão valida as opções, e a primeira chamada lança OptionsValidationException: "Total request timeout resilience strategy must have a greater timeout than the attempt resilience strategy" (medido no .NET 10, com o pacote 10.10). A validação existe porque a combinação não faz sentido: o timeout total, de 6 s, envolve todas as tentativas, então a primeira tentativa seria cortada aos 6 s, antes dos 10 s dela, e nenhuma das 3 retentativas chegaria a acontecer. A correção segue o orçamento: timeout por tentativa menor que o total dividido pelo número de tentativas somado às esperas entre elas. Por exemplo, total de 6 s, tentativa de 1,5 s, 2 retentativas com espera exponencial curta (cerca de 0,2 s e 0,4 s). Assim há espaço real para três tentativas, e uma réplica travada é abandonada a tempo de tentar outra.
Exercício 2
Por que o CacheCondicional fica por fora do AddStandardResilienceHandler? O que mudaria com a ordem invertida?
Ver resposta
✓ Resposta: Por fora (registrado primeiro), o cache é consultado uma vez por chamada lógica: ele acrescenta If-None-Match, e a resiliência cuida das tentativas de rede com esse cabeçalho já presente. Uma resposta 304 é convertida em 200 fora da resiliência, que não precisa entender nada de cache. Com a ordem invertida, o cache ficaria dentro de cada tentativa. Cada retentativa passaria por ele de novo, o que é inofensivo mas redundante. O mais importante: o circuit breaker e os limitadores de concorrência passariam a observar respostas vindas do cache como se fossem da rede, distorcendo as estatísticas de falha e sucesso da API. A regra geral é que aquilo que evita ir à rede fica por fora, e aquilo que trata falhas de rede fica por dentro.
Exercício 3
O navegador chama /api/v1/pedidos pelo proxy, e a API responde 401 porque o access token expirou há um minuto. O que o BFF deveria fazer, e por que isso não deve ser resolvido no JavaScript do navegador?
Ver resposta
✓ Resposta: O BFF deve renovar o token do lado do servidor antes de encaminhar: verificar a expiração guardada junto com o token na sessão, e se faltar pouco (ou já tiver passado), usar o refresh token, também guardado no servidor, para obter um novo par no provedor de identidade, atualizar a sessão e então encaminhar a requisição com o token novo. Se a renovação falhar, porque o refresh token foi revogado ou expirou, responde 401 ao navegador, e a aplicação redireciona para o login. Resolver no navegador exigiria que o JavaScript tivesse acesso ao refresh token, que é justamente a credencial mais valiosa e de vida mais longa, e o BFF existe para nunca expô-la. Bibliotecas de BFF, como o Duende.BFF, e o próprio middleware de OpenID Connect com SaveTokens oferecem essa renovação automática, que é parte do próximo artigo.
Exercício 4
Escreva um teste de unidade da resiliência que prove que um POST não é repetido quando a API responde 503, e que um GET é.
Ver resposta
✓ Resposta: Crie um DelegatingHandler falso que conta chamadas e sempre devolve HttpStatusCode.ServiceUnavailable. Monte um contêiner: var servicos = new ServiceCollection(); servicos.AddHttpClient("teste", c => c.BaseAddress = new Uri("http://api/")).ConfigurePrimaryHttpMessageHandler(() => falso).AddStandardResilienceHandler(o => { o.Retry.MaxRetryAttempts = 2; o.Retry.Delay = TimeSpan.Zero; o.Retry.DisableForUnsafeHttpMethods(); }); (nessa ordem: AddStandardResilienceHandler devolve outro tipo de builder, e encadear o ConfigurePrimaryHttpMessageHandler depois dele não compila, CS1929). Resolva o IHttpClientFactory e crie o cliente. Chame PostAsync("pedidos", conteudo), verifique Assert.Equal(1, falso.Chamadas), zere o contador, chame GetAsync("produtos/1") e verifique Assert.Equal(3, falso.Chamadas): 1 tentativa mais 2 retentativas. Um cuidado: com Delay = TimeSpan.Zero o teste é rápido, mas o circuit breaker pode abrir se a amostragem vir falhas suficientes. Ajuste MinimumThroughput para um valor alto no teste, ou use instâncias separadas para cada caso.
Exercício 5
Depois de introduzir o YARP, alguém propõe remover a política de CORS da API "porque agora tudo passa pelo site". Em que situação essa remoção quebraria algo, e quando ela seria segura?
Ver resposta
✓ Resposta: CORS só importa para navegadores chamando a API diretamente de outra origem. A remoção quebraria qualquer front-end de navegador que ainda chame a API sem passar pelo proxy: uma versão antiga do carrinho em cache no navegador de usuários durante a transição, um painel interno servido de outro domínio, uma página de parceiro. Aplicativos móveis, o jogo e outros serviços não são afetados, porque não aplicam CORS. A remoção é segura quando: (1) os logs e métricas mostram que nenhuma requisição com cabeçalho Origin de navegador chega à API diretamente, por um período que cubra os caches dos clientes; (2) a API não está exposta publicamente para navegadores, idealmente acessível só pela rede interna, pelo site e pelos clientes nativos via gateway. Remover a configuração sem essa verificação é uma mudança incompatível silenciosa, como o exercício 3 de "Produção" discutiu para versões.