Pular para o conteúdo principal

Erros

Quando usar: toda recusa. O cliente do módulo (a Console, outro serviço, o script de operação) trata erro pelo código e pelo reason — nunca pela mensagem, que é para o humano e pode mudar.

O Error Model é fechado em 13 códigos, cada um com o HTTP dele e um retryable padrão:

CódigoHTTPRetryableQuando
validation_failed400nãoRequisição inválida
unauthenticated401nãoAutenticação necessária
permission_denied403nãoPermissão negada
not_found404nãoRecurso não encontrado
conflict409nãoConflito com o estado atual
payload_too_large413nãoCorpo grande demais
rate_limited429simLimite de requisições
internal500nãoErro interno
not_supported501nãoOperação não suportada
dependency_unavailable503simDependência indisponível
not_ready503nãoServiço não pronto para esta operação
insufficient_capacity503nãoCapacidade insuficiente
timeout504nãoTempo esgotado

Recusar​

// 400 com o campo em `details` — o cliente usa para apontar no formulário.
throw ApiError.of(Code.VALIDATION_FAILED, "invalid_name", "name: 1 a 80 caracteres, sem controle.")
.withDetail("field", "name");

// 404 que NÃO revela se o recurso existe para outro tenant.
throw ApiError.of(Code.NOT_FOUND, Scan.REASON_ITEM_NOT_FOUND, "Item não encontrado.");

// 409 com o que o cliente precisa para reagir.
throw ApiError.of(Code.CONFLICT, Scan.REASON_SCAN_IN_PROGRESS, "Há uma varredura em andamento neste item.")
.withDetail("action_id", it.scanActionId());

// 503 que vale a pena tentar de novo.
throw ApiError.of(Code.DEPENDENCY_UNAVAILABLE, "database_unavailable", "Banco indisponível.")
.withRetryable(true);

A resposta sai no envelope do contrato:

{"error":{"code":"permission_denied","reason":"permission_required",
"message":"Permissão sample.context.read necessária.","retryable":false,
"details":{"permission":"sample.context.read"}}}

As regras que valem a pena​

  • reason é contrato; a mensagem não é. item_name_taken nasce uma vez, numa constante, e nunca muda de texto nem de grafia. É o que o cliente compara.
  • Um reason por situação. Não reuse invalid para tudo: o cliente não consegue reagir.
  • details ajuda a agir, não despeja o objeto. Um id, um campo, um limite.
  • Nada de texto de exceção na resposta. Exceção que não é ApiError vira 500 internal sem texto: nem stack trace, nem mensagem do banco, nem nome de tabela.
  • O HTTP vem do código, nunca escolhido à mão: quem devolve 200 com {"error":...} quebra o cliente e o gateway.
  • Não invente código novo. Se a situação não cabe nos 13, ela é uma das 13 com outro reason.

Ler o corpo com o leitor estrito​

Recusar corpo malformado é a outra metade: o corpo é entrada.

private static SampleService.CreateInput decodeCreate(byte[] body) {
SampleService.CreateInput raw;
try {
raw = StrictJson.unmarshal(body, SampleService.CreateInput.class);
} catch (RuntimeException e) {
throw Wire.invalidBody(); // 400 validation_failed/invalid_body
}
return SampleService.CreateInput.of(raw.name(), raw.description());
}
/** 400 para um corpo que não é o JSON esperado (campo desconhecido, tipo errado, vazio). */
static ApiError invalidBody() {
return ApiError.of(Code.VALIDATION_FAILED, "invalid_body", "Corpo JSON inválido ou com campo desconhecido.");
}

O leitor estrito recusa: campo desconhecido, chave duplicada, tipo errado, conteúdo sobrando depois do valor, BOM, aninhamento absurdo. O que ele não faz é adivinhar: "limit": "50" não vira 50.

O teto do corpo é da rota (max_body_bytes, no api.yaml), e o handler lê com o teto dele:

byte[] readBody(NnsBody body, long limit) {
try {
return body.read(limit);
} catch (MaxBytesException e) {
throw ApiError.of(Code.PAYLOAD_TOO_LARGE, "body_too_large", "Corpo acima do limite da rota.");
} catch (UncheckedIOException e) {
throw ApiError.of(Code.VALIDATION_FAILED, "body_unreadable", "Corpo ilegível.");
}
}

O que o runtime já recusa por você​

SituaçãoResposta
Rota que não existe no api.yaml404 not_found/route_not_found
Caminho não canônico (//items, .. no caminho)400 validation_failed
Sem token, ou token inválido/expirado401 unauthenticated
Token válido sem a permissão da rota403 permission_denied/permission_required
Rota interna com token de usuário401 unauthenticated
Rota interna com token de serviço sem o escopo403 permission_denied/scope_required
Prazo da rota esgotado504 timeout

Nunca 405, 406, 415 nem redirect: o roteador do descritor responde no Error Model.

Como testar​

@Test
void routeThatDoesNotExistIs404RouteNotFound() {
String token = ctxToken(ctx("whoami", "sample.context.read"), "GET", "/v1/sample/nao-existe", null);
HttpResponse<String> r = call("GET", "/v1/sample/nao-existe", token, null);
assertThat(r.statusCode()).isEqualTo(404);
JsonNode error = body(r).get("error");
assertThat(error.get("code").asText()).isEqualTo("not_found");
assertThat(error.get("reason").asText()).isEqualTo("route_not_found");
}

E o lado do banco, que é onde as razões de negócio nascem:

assertThatThrownBy(() -> ItemStore.create(CTX, db, item("t1", "acao", T0)))
.isInstanceOfSatisfying(
ApiError.class,
e -> {
assertThat(e.code()).isEqualTo(Code.CONFLICT);
assertThat(e.reason()).isEqualTo(Scan.REASON_ITEM_NAME_TAKEN);
assertThat(e.details()).containsEntry("name", "acao");
});

Erros comuns​

  • throw new RuntimeException(...) esperando 400 — vira 500 internal, sem texto. Toda recusa prevista é ApiError.
  • Mensagem com dado de outro tenant — "item X não existe" para um item que existe em outro tenant já é vazamento de existência. O 404 é o mesmo nos dois casos.
  • withDetail com o objeto inteiro — details vai para o cliente, para o log e para o audit.
  • Devolver 200 com {"ok": false} — o cliente não trata, o gateway não mede, o alerta não soa.
  • Usar reason com acento, espaço ou maiúscula — a forma é ^[a-z][a-z0-9_]{0,63}$.
  • Confundir dependency_unavailable com not_ready — o primeiro é "tente de novo", o segundo é "esta réplica não faz isso agora" (desligamento, por exemplo).
  • Deixar exceção do banco vazar — DbException crua vira 500 sem texto; se o certo for 503 retryable, classifique no handler.