backendsecurity

Debug do Export LGPD: 409 Opaco para 429 com Retry-After

· 5 min de leitura

Nesta semana, no MoWave One, a gente resolveu uma reclamação persistente de usuários: “Minha exportação de dados LGPD nunca chega.” Por um tempo, isso foi um problema intrigante. Da nossa perspectiva, o serviço estava se comportando exatamente como planejado. No entanto, do ponto de vista do usuário do app Lima, a solicitação de dados pessoais simplesmente desaparecia, fazendo com que um recurso perfeitamente funcional parecesse quebrado. Este post detalha como a gente diagnosticou e corrigiu o comportamento opaco do cooldown de exportação LGPD.

O Problema: “Exportação Quebrada, Nada Chega”

O Lima oferece um endpoint síncrono para exportação de dados LGPD em GET /api/v1/lgpd/export. Este endpoint é projetado para retornar um 200 OK com o payload JSON completo para 14 módulos de dados, junto com um header Content-Disposition: attachment. Não tem job ID, nem processamento assíncrono, é um download direto. Isso é conveniente para exportações pequenas, mas, como a gente vai discutir, tem seus próprios desafios para conjuntos de dados maiores.

Usuários, especialmente aqueles que estavam tentando exportar seus dados várias vezes em um curto período, relatavam que a exportação simplesmente não iniciava. Eles clicavam no botão, esperavam, e nada era baixado. A UI do app não oferecia feedback além do estado de carregamento típico, eventualmente dando timeout ou simplesmente não fazendo nada. Isso criava uma experiência frustrante onde uma solicitação perfeitamente válida parecia falhar silenciosamente toda vez que eles tentavam novamente.

A Causa Técnica: Um Cooldown Opaco

Investigar os logs do servidor revelou a história real. O endpoint GET /api/v1/lgpd/export impõe um cooldown: uma exportação por usuário por hora. Esse cooldown é gerenciado usando Redis, especificamente tentando uma operação setIfAbsent. Se a chave já existisse (o que significa que uma exportação ocorreu na última hora), nosso código lançava uma IllegalStateException. Nosso handler de exceção genérico então mapeava essa IllegalStateException para uma resposta HTTP 409 INVALID_STATE.

Aqui está a parte crítica: a resposta 409 não oferecia nenhuma informação adicional. Ela não dizia por que o estado era inválido, nem sugeria quando o usuário poderia tentar novamente. Do ponto de vista do app, um 409 INVALID_STATE é apenas mais uma variação de “algo deu errado no servidor”. O cliente não conseguia diferenciar entre um erro genuíno do servidor, um problema temporário ou um rate limit perfeitamente válido. Cada nova tentativa do usuário, dentro daquela janela de uma hora, simplesmente bateria no mesmo 409 opaco.

Nossa verificação no Redis se parecia com isto (simplificado):

// Inside LgpdService.java
public JsonNode exportUserData(String userId) {
    if (redisClient.setIfAbsent(userId + ":lgpd_export_cooldown", "locked", 3600)) {
        // proceed with export logic
    } else {
        throw new IllegalStateException("LGPD export cooldown active");
    }
}

E o tratamento de exceção genérico:

// Inside GlobalExceptionHandler.java
@ExceptionHandler(IllegalStateException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleIllegalStateException(IllegalStateException ex) {
    return new ErrorResponse("INVALID_STATE", ex.getMessage());
}

Esse mapeamento genérico era a raiz do problema. Ele tratava uma regra de negócio específica (cooldown) como um erro de estado geral da aplicação, removendo qualquer contexto útil para o cliente.

A Correção: Sinais Mais Claros com Retry-After

Em 2026-07-12, a gente fez um deploy de uma correção para resolver essa ambiguidade. O cerne da solução foi introduzir uma exceção dedicada para o cooldown de exportação LGPD e mapeá-la para um código de status HTTP mais apropriado com um header Retry-After.

Primeiro, a gente criou uma LgpdExportCooldownException específica. Em seguida, a gente atualizou o GlobalExceptionHandler para capturar essa nova exceção e retornar 429 LGPD_EXPORT_COOLDOWN junto com um header Retry-After. Este header contém o número exato de segundos que o usuário precisa esperar antes de tentar novamente. Para o caso de borda onde o Redis pode retornar um TTL nulo, a gente define o Retry-After para 3600 segundos (uma hora) por padrão.

// Inside GlobalExceptionHandler.java (simplified for brevity)
@ExceptionHandler(LgpdExportCooldownException.class)
public ResponseEntity<ErrorResponse> handleLgpdExportCooldownException(LgpdExportCooldownException ex) {
    HttpHeaders headers = new HttpHeaders();
    headers.add("Retry-After", String.valueOf(ex.getRemainingSeconds()));
    return new ResponseEntity<>(new ErrorResponse("LGPD_EXPORT_COOLDOWN", ex.getMessage()), headers, HttpStatus.TOO_MANY_REQUESTS);
}

Crucialmente, a gente também garantiu que o cooldown seja automaticamente limpo se o próprio processo de exportação falhar. Isso evita que um problema transitório no backend impeça o usuário de fazer uma nova tentativa por uma hora após uma exportação falha. O 409 INVALID_STATE genérico agora permanece apenas para cenários realmente raros e “fail-closed”, como quando o próprio Redis está inacessível, o que impediria a verificação do cooldown de sequer acontecer.

Após essas mudanças, nosso LgpdService agora tem 94% de cobertura de linha e 81% de cobertura de branch, com GlobalExceptionHandler em 97% de linha e 89% de cobertura de branch, e todos os 144 testes passando. Isso nos dá confiança na robustez do novo comportamento.

Um item conhecido do backlog permanece: uma exportação síncrona de vários MB ainda pode atingir timeouts do cliente para usuários antigos. A solução documentada para isso é migrar para um fluxo assíncrono (202 Accepted + jobId + entrega por e-mail), que é uma mudança arquitetural maior que a gente adiou por enquanto.

Conclusão

Este incidente reforçou um princípio fundamental: rate limits e cooldowns não são apenas mecanismos de imposição de backend, eles são superfícies de experiência do usuário. Se o seu aplicativo cliente não consegue distinguir entre um estado legítimo de “bloqueado” e um estado geral de “quebrado”, e não consegue informar ao usuário quanto tempo esperar, seu comportamento de backend perfeitamente correto se manifestará como uma interrupção da perspectiva do usuário. Fornecer feedback claro e acionável, como um header Retry-After, transforma um mistério frustrante em uma instrução transparente. Você pode saber mais sobre o Lima e como a gente constrói nossos recursos em https://getlima.app.