Jogando fora o excesso com Graceful Degradation

Implementando Load Shedding para Evitar a Falha Total

Você já viu este cenário: o sistema está sob alta carga, o banco de dados começa a responder 200ms mais devagar e, de repente, todos os microserviços travam. O Kubernetes reinicia os pods, mas eles voltam e caem imediatamente.

Isso é um Cascading Failure (Falha em Cascata). E a culpa, muitas vezes, é da nossa tentativa de "salvar" todas as requisições.

A intuição nos diz para enfileirar requisições quando o servidor está cheio. A engenharia de resiliência nos diz o oposto: jogue fora o excesso. Isso é Load Shedding (Descarte de Carga).



O Conceito: Load Shedding vs. Circuit Breaker

É vital não confundir os dois padrões:

  • Circuit Breaker: Protege o seu sistema de um dependente que falhou (ex: "O serviço de Pagamento caiu, pare de chamar ele").

  • Load Shedding: Protege o seu sistema de si mesmo ou de um cliente agressivo (ex: "Estou sem CPU, vou rejeitar essa requisição de relatório para salvar o checkout").

O Load Shedding opera no princípio de que é melhor servir 80% dos usuários com baixa latência do que 100% dos usuários com timeout ou erro 500.

Implementação em C# (.NET Core)

No ecossistema .NET, podemos implementar um Load Shedding inteligente via Middleware. Diferente de um Rate Limiter (que limita por usuário), o Load Shedder olha para a saúde global do servidor (ex: uso de memória, thread pool ou fila de processamento).

Abaixo, um exemplo de Middleware que rejeita requisições novas se a fila de processamento interno estiver cheia.


using System.Net;

public class LoadSheddingMiddleware

{

    private readonly RequestDelegate _next;

    // Define a capacidade máxima de concorrência "segura" para este serviço

    private readonly int _maxConcurrentRequests = 100;

    private static int _currentRequests = 0;

    public LoadSheddingMiddleware(RequestDelegate next)

    {

        _next = next;

    }

    public async Task InvokeAsync(HttpContext context)

    {

        // 1. Verificação Atômica de Capacidade

        // Se já estamos no limite, NÃO enfileiramos. Rejeitamos na porta.

        if (Interlocked.Increment(ref _currentRequests) > _maxConcurrentRequests)

        {

            // Rollback do incremento pois não vamos processar

            Interlocked.Decrement(ref _currentRequests);

            // 2. Load Shedding em ação

            // Retornamos 503 (Service Unavailable) imediatamente.

            // Isso libera a conexão TCP e a Thread quase instantaneamente.

            context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;

            context.Response.Headers["Retry-After"] = "5"; // Dica para o cliente (Backoff)

            await context.Response.WriteAsync("System Overloaded. Load Shedding active.");

            return;

        }

        try

        {

            await _next(context);

        }

        finally

        {

            // 3. Libera a capacidade ao terminar

            Interlocked.Decrement(ref _currentRequests);

        }

    }

}

// No Program.cs

// app.UseMiddleware<LoadSheddingMiddleware>();


O que está acontecendo aqui?

Diferente de um SemaphoreSlim.WaitAsync(), que colocaria a requisição em espera (consumindo memória e socket), o if atômico decide em nanossegundos se a requisição deve morrer. Isso preserva a CPU para as 100 requisições que já estão sendo processadas.


Implementação em Rust (Tower)

Rust tem uma cultura fortíssima de resiliência. A biblioteca padrão para middlewares assíncronos, Tower, possui um conceito nativo de LoadShed.

Em Rust, o modelo é ainda mais elegante: se o serviço interno diz "não estou pronto" (Poll::Pending), a camada de Load Shedding transforma isso automaticamente em erro, sem nem tentar alocar o futuro da requisição.

Vamos usar o tower com ServiceBuilder.


use tower::{ServiceBuilder, ServiceExt, Service};

use tower::load_shed::LoadShedLayer;

use tower::limit::ConcurrencyLimitLayer;

use http::{Request, Response, StatusCode};

use hyper::Body;

use std::convert::Infallible;

async fn handle_request(_req: Request<Body>) -> Result<Response<Body>, Infallible> {

    // Simula processamento pesado

    tokio::time::sleep(std::time::Duration::from_millis(50)).await;

    Ok(Response::new(Body::from("Processado com sucesso!")))

}

#[tokio::main]

async fn main() {

    // Construção da Stack de Middleware

    let service = ServiceBuilder::new()

        // 1. Define o limite físico. Se tiver 10 requisições voando,

        // o serviço interno retorna "Pending" (Não estou pronto).

        .layer(ConcurrencyLimitLayer::new(10))   

        // 2. O LoadShed detecta o "Pending". Em vez de esperar (buffer),

        // ele retorna um erro IMEDIATAMENTE.

        .layer(LoadShedLayer::new())      

        .service_fn(handle_request);

    // Exemplo de como o "servidor" chamaria esse serviço

    // (Simplificado para fins didáticos)

    let mut protected_service = service;

    // Simulando 20 requisições simultâneas

    for i in 0..20 {

        // Precisamos clonar para simular concorrência real no tokio::spawn

        let mut svc = protected_service.clone();        

        tokio::spawn(async move {

            // O `ready()` verifica se o serviço aguenta mais carga

            // O LoadShedLayer fará essa chamada falhar rápido se estiver cheio.

            match svc.ready().await {

                Ok(_) => {

                    let _ = svc.call(Request::new(Body::empty())).await;

                    println!("Req {}: Sucesso", i);

                }

                Err(_) => {

                    // AQUI ocorre o Load Shedding

                    println!("Req {}: DROPADO (Load Shedding)", i);

                }

            }

        });

    }

}


A Diferença do Rust

No exemplo em Rust, a combinação de ConcurrencyLimitLayer + LoadShedLayer cria um comportamento perfeito de descarte. O ConcurrencyLimit controla o estado ("estou cheio"), e o LoadShed toma a ação ("então rejeite o resto"). Não há "if" manual; é composição de comportamento.

Conclusão

Implementar Load Shedding é uma decisão difícil. Significa aceitar, conscientemente, que você vai falhar requisições de usuários.

Porém, sob a ótica da Engenharia de Software e Liderança Técnica, é a decisão madura. Ao sacrificar o excedente, você garante a sobrevivência do núcleo do sistema. Sem isso, em um pico de tráfego, a taxa de sucesso cai para 0% para todos. Com Load Shedding, ela se mantém estável no limite da sua capacidade.