Tratamento de erros

Três perguntas que todo pipeline acaba fazendo, e que esta página responde na ordem: o que acontece quando um step falha, como isso vira a resposta HTTP, e como evitar devolver 500 quando a falha era esperada.

O caminho de uma falha

Um step que falha não lança exceção: ele devolve uma falha estruturada com quatro campos.

Campo O que é
code O identificador da falha — db_persist_failed, http_call_failed, ou o que você declarou num fail
message Texto legível
status O status HTTP que a falha sugere, quando ela tem um
details Dado estruturado extra, quando o step tem algo a anexar

A partir daí, a falha interrompe o pipeline e a camada HTTP a converte numa resposta RFC 9457 ProblemDetails, consultando a tabela errors: da rota pelo code:

{
  "type": "https://pipevine.dev/errors/CUSTOMER_NOT_FOUND",
  "title": "Cliente não encontrado",
  "detail": "cliente não encontrado",
  "status": 404,
  "code": "CUSTOMER_NOT_FOUND"
}

title vem da entrada em errors: quando existe; senão, da message da falha. details, quando a falha tem, aparece como um campo extra de mesmo nome.

Todo code que uma execução pode produzir está no catálogo de códigos de erro — inclusive os que nenhum fail seu gerou.

A tabela errors: da rota

- id: createOrder
  method: POST
  path: /orders
  pipeline:
    - { type: db.find, id: customer, entity: Customer, where: { id: "input.body.customerId" } }
    - { type: fail, when: "steps.customer.result = null", status: 404, code: CUSTOMER_NOT_FOUND }
    - { type: http.call, id: price, integration: pricing, method: POST, path: /quote }
  errors:
    - { code: CUSTOMER_NOT_FOUND, status: 404, title: "Cliente não encontrado" }
    - { code: http_call_failed,   status: 503, title: "Serviço de preços indisponível" }

A segunda entrada é a resposta direta para "como faço para não devolver 500": a falha do http.call passa a sair como 503 com um título próprio, em vez do status cru do upstream.

status: é obrigatório na prática

Uma entrada de errors: que casa sempre impõe o próprio status: — e omiti-lo não significa "herda o status da falha", significa 500.

# ARMADILHA: responde 500, não 404
- { type: fail, when: "...", status: 404, code: CUSTOMER_NOT_FOUND }
errors:
  - { code: CUSTOMER_NOT_FOUND, title: "Cliente não encontrado" }   # sem status:

O 404 do fail é descartado. Declare status: em toda entrada de errors:, ou não declare a entrada — sem entrada nenhuma, o status da própria falha é respeitado.

O que não passa pela tabela errors:

Três respostas de erro são produzidas antes de o pipeline começar, e por isso nenhuma entrada de errors: as alcança:

Código Quando Status
binding_failed Header ou parâmetro de query obrigatório ausente, corpo JSON malformado 400
unauthorized Token ausente, expirado, ou com issuer/audience/assinatura inválidos 401
forbidden Autenticado, mas sem o escopo/papel/claim exigido, ou authorize.expr falso 403

Se você precisa customizar essas respostas, o lugar é a configuração de auth e de request, não a tabela errors:.

Os três mecanismos, e quando usar cada um

Mecanismo Onde age Use quando
resilience.fallback Na integração, antes de o step falhar A dependência está fora do ar e você tem um valor aceitável para seguir em frente
errors: Depois da falha, ao montar a resposta A falha deve mesmo interromper, mas com status/título melhores que o default
try/catch Dentro do pipeline O pipeline deve continuar apesar da falha, fazendo outra coisa

Eles compõem, e a ordem importa: o fallback age primeiro (o step nem chega a falhar), o try/catch intercepta a falha antes que ela suba, e a tabela errors: só entra em cena se a falha chegou ao fim do pipeline.

try/catch: seguir apesar da falha

- type: try
  try:
    - { type: http.call, id: price, integration: pricing, method: POST, path: /quote }
  catch:
    - { type: log, message: "consulta de preço falhou, usando preço padrão" }
    - { type: map, to: vars.price, expr: "0" }

Dentro do catch, a falha fica disponível em error — mas com duas formas diferentes, e essa diferença já causou surpresa:

Origem Forma de error
Falha de um step { code, message, status, details }
Exceção não tratada dentro do try { code: "exception", message, type } — sem status nem details

Ou seja: error.status existe no primeiro caso e some no segundo, sem aviso. Se o seu catch lê error.status, trate a ausência.

Três detalhes do try que não são óbvios:

  • try sem catch não engole nada: a falha continua subindo. O bloco só serve, nesse caso, para pendurar um finally.
  • Uma exceção só é capturada se houver catch. Sem ele, ela sobe como exceção — e vira o 500 opaco da seção seguinte.
  • finally sobrepõe o resultado: se o bloco finally terminar com qualquer coisa que não seja "continuar" (um fail, um return), é esse o resultado que vale, descartando o que o try ou o catch haviam produzido. Ele também roda sem cancelamento — um cliente que desistiu não interrompe o finally.

Exceção não tratada: o 500 opaco

Uma exceção que ninguém capturou não é um erro de aplicação esperado — é um bug. A resposta é deliberadamente vazia de detalhes:

{
  "type": "https://pipevine.dev/errors/internal-error",
  "title": "An unexpected error occurred.",
  "status": 500
}

Nem a mensagem da exceção nem o stack trace vazam para o cliente. Para diagnosticar, o caminho é a telemetria — veja Observabilidade.

Exemplo trabalhado: http.call

O http.call é o step onde as três perguntas do começo aparecem juntas, porque o resultado dele não fica no mesmo lugar em todos os casos:

Situação Onde o valor fica Falha
2xx steps.<id>.result, só o corpo (sem status, sem headers) — e só se houver id: —
4xx Em lugar nenhum. O corpo vai para details da falha http_call_failed, status do upstream
5xx com fallback.expr steps.<id>.result recebe o valor da expressão —
5xx com fallback em sub-pipeline Nada em steps.<id>.result — os steps de dentro gravam o próprio —
5xx sem fallback — http_call_failed, status do upstream
Timeout / circuito aberto — http_call_failed, 502

Juntando tudo, para que uma indisponibilidade do serviço de preços não derrube a requisição:

integrations:
  - id: pricing
    kind: http
    baseUrl: "${config:Pricing:Url}"
    resilience:
      timeout: { perAttempt: 2s }
      retry:   { attempts: 3, on: [502, 503, 504] }
      fallback: { expr: "{ 'price': 0, 'degraded': true }" }   # 1: nem chega a falhar
routes:
  - id: quote
    method: POST
    path: /quote
    pipeline:
      - { type: http.call, id: price, integration: pricing, method: POST, path: /quote }
      - { type: map, to: vars.total, expr: "steps.price.result.price" }
    errors:
      - { code: http_call_failed, status: 503, title: "Serviço de preços indisponível" }   # 2: se ainda assim falhar

Com o fallback, um 5xx vira { price: 0, degraded: true } publicado em steps.price.result, e o pipeline segue como se tivesse dado certo — repare que vars.total funciona igual nos dois caminhos. A entrada em errors: é a rede de segurança para o que o fallback não cobre: um 4xx, que nunca aciona fallback.

Para onde ir daqui