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ódigo | HTTP | Retryable | Quando |
|---|---|---|---|
validation_failed | 400 | não | Requisição inválida |
unauthenticated | 401 | não | Autenticação necessária |
permission_denied | 403 | não | Permissão negada |
not_found | 404 | não | Recurso não encontrado |
conflict | 409 | não | Conflito com o estado atual |
payload_too_large | 413 | não | Corpo grande demais |
rate_limited | 429 | sim | Limite de requisições |
internal | 500 | não | Erro interno |
not_supported | 501 | não | Operação não suportada |
dependency_unavailable | 503 | sim | Dependência indisponível |
not_ready | 503 | não | Serviço não pronto para esta operação |
insufficient_capacity | 503 | não | Capacidade insuficiente |
timeout | 504 | não | Tempo 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_takennasce uma vez, numa constante, e nunca muda de texto nem de grafia. É o que o cliente compara.- Um
reasonpor situação. Não reuseinvalidpara tudo: o cliente não consegue reagir. detailsajuda 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 é
ApiErrorvira 500internalsem 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ção | Resposta |
|---|---|
Rota que não existe no api.yaml | 404 not_found/route_not_found |
Caminho não canônico (//items, .. no caminho) | 400 validation_failed |
| Sem token, ou token inválido/expirado | 401 unauthenticated |
| Token válido sem a permissão da rota | 403 permission_denied/permission_required |
| Rota interna com token de usuário | 401 unauthenticated |
| Rota interna com token de serviço sem o escopo | 403 permission_denied/scope_required |
| Prazo da rota esgotado | 504 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 500internal, 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.
withDetailcom o objeto inteiro —detailsvai 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
reasoncom acento, espaço ou maiúscula — a forma é^[a-z][a-z0-9_]{0,63}$. - Confundir
dependency_unavailablecomnot_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 —
DbExceptioncrua vira 500 sem texto; se o certo for 503 retryable, classifique no handler.
Was this page helpful?
Report a problem on this pageDo not send passwords, keys, tokens, or customer data.