Hookway

On error — on_error

state_stores:
  local:
    namespace: hookway
    memory: {} # Per process; select Redis or PostgreSQL for shared protection.

# examples/on_error.yaml
#
# on_error is a reserved output port on nodes that can fail with a recoverable
# input. If the node returns an error and an on_error edge exists, the flow
# continues with that node's *input* (not its output) and the request succeeds
# unless the error path itself fails.
# Protection store failures/capacity/policy conflicts always fail closed with
# 503. Quota/duplicate/missing-key recovery may render local responses, but may
# never dispatch publish, HTTP, registration or child work. A replay guard is
# retained after any earlier successful or uncertain outbound effect, including
# an HTTP attempt followed by DLQ recovery.
#
# Type rule: the error-path target must consume the failing node's input port.
# http_trigger, read_body, and no_auth cannot declare on_error. A node still
# needs a success path; on_error-only wiring is rejected at validate.
#
# Without on_error, the failure becomes the HTTP status from the node
# (401, 413, 429, 502, …) or 500. Filter on_no_match: drop is a successful
# stop, not an error, so it does not take this port.
#
# See docs/reference/configuration.md § Flows.

server:
  addr: ":8080"
  max_body_bytes: 10485760

destinations:
  primary:
    type: webhook
    webhook:
      url: "${WEBHOOK_URL}"
  dlq:
    type: webhook
    webhook:
      url: "${WEBHOOK_URL}"

flows:
  # Ingest: POST the decoded event to an ack URL. 5xx from that URL publishes
  # the original envelope to dlq instead of failing the webhook (sender sees
  # 202 if the DLQ publish succeeds).
  github_ack_or_dlq:
    nodes:
      - {id: in, type: http_trigger, description: "GitHub posts webhook deliveries here.", path: /webhooks/github, methods: [POST]}
      - {id: rl, type: rate_limit, store: local, description: "Caps inbound traffic before any body is read.", rpm: 60}
      - {id: body, type: read_body, description: "Reads the raw body, rejecting anything over 1 MiB.", max_bytes: 1048576}
      - {id: auth, type: auth, description: "Verifies the GitHub HMAC signature over the raw body.", scheme: hmac_sha256_hex, secret: "${GITHUB_WEBHOOK_SECRET}", signature_header: X-Hub-Signature-256}
      - {id: env, type: envelope, description: "Builds the JSON event envelope consumers receive.", body_format: json}
      - {id: ack, type: http_request, description: "Posts the event to the ack URL; transient failures retry, then a 5xx takes the error port.", url: "https://example.test/hooks", timeout: 5s, error_on_status: [5xx], retry: {max_attempts: 3, initial_backoff: 500ms, max_backoff: 5s}}
      - {id: out, type: publish, description: "Normal path: deliver the acknowledged envelope.", destination: primary, target: github-webhooks}
      - {id: dlq, type: publish, description: "Error path: park the envelope so the sender still gets a 202.", destination: dlq, target: github-failed}
    edges: [in>rl, rl>body, body>auth, auth>env, env>ack, ack>out, ack:on_error>dlq]

  # Sync proxy: upstream timeout or 5xx becomes an explicit 502 to the sender
  # instead of a 500 from an unhandled node error.
  proxy_or_502:
    nodes:
      - {id: in, type: http_trigger, description: "Accepts any method under the proxy prefix.", path: "/proxy/{rest...}", methods: [ANY]}
      - {id: rl, type: rate_limit, store: local, description: "Caps proxy traffic before any body is read.", rpm: 60}
      - {id: body, type: read_body, description: "Buffers the body so it can be forwarded upstream.", max_bytes: 10485760, allow_empty: true}
      - {id: auth, type: auth, description: "Compares the inbound X-Auth header to the shared token.", scheme: exact, secret: "${PROXY_TOKEN}", signature_header: X-Auth}
      - {id: env, type: envelope, description: "Leaves the body unparsed so the proxy stays byte-exact.", body_format: raw}
      - {id: up, type: http_request, description: "Forwards upstream; a timeout or 5xx takes the error port.", url: "${UPSTREAM_BASE}/{rest}", forward_method: true, forward_query: true, remove_headers: [X-Auth], timeout: 5s, error_on_status: [5xx]}
      - {id: resp, type: respond, description: "Normal path: return the upstream response unchanged.", passthrough: true}
      - {id: fail, type: respond, description: "Error path: an explicit 502 instead of an unhandled 500.", status: 502, body: "upstream failed"}
    edges: [in>rl, rl>body, body>auth, auth>env, env>up, up>resp, up:on_error>fail]

  # Rate limit: on_error carries RequestHead. The fallback still has to read
  # the body before it can respond 429 instead of the default rate-limit status.
  rate_or_429:
    nodes:
      - {id: in, type: http_trigger, description: "Endpoint with a deliberately tight rate limit.", path: /webhooks/limited, methods: [POST]}
      - {id: rl, type: rate_limit, store: local, description: "One request per burst; the rest take the error port.", rpm: 60, burst: 1, key_by: source_ip}
      - {id: body, type: read_body, description: "Normal path: read the accepted request body.", max_bytes: 1048576}
      - {id: auth, type: no_auth, description: "No signature to check on this endpoint."}
      - {id: env, type: envelope, description: "Builds the JSON envelope for the accepted request.", body_format: json}
      - {id: ok, type: respond, description: "Normal path: acknowledge with 200.", status: 200, body: ok}
      - {id: body2, type: read_body, description: "Error path: the 429 branch must read the body itself.", max_bytes: 1048576}
      - {id: na2, type: no_auth, description: "Error path: marks the request verified so envelope can run."}
      - {id: env2, type: envelope, description: "Error path: envelope needed before respond can be reached.", body_format: json}
      - {id: limited, type: respond, description: "Error path: an explicit 429 for the shed request.", status: 429, body: "rate limited"}
    edges: [in>rl, rl>body, body>auth, auth>env, env>ok, rl:on_error>body2, body2>na2, na2>env2, env2>limited]

  # Auth failure: on_error carries RawRequest. no_auth marks it verified so
  # envelope can decode and the sender gets 401 instead of the default auth status.
  auth_or_401:
    nodes:
      - {id: in, type: http_trigger, description: "Endpoint expecting a signed payload.", path: /webhooks/signed, methods: [POST]}
      - {id: body, type: read_body, description: "Reads the raw body so the signature can be checked.", max_bytes: 1048576}
      - {id: auth, type: auth, description: "Verifies the HMAC; a bad signature takes the error port.", scheme: hmac_sha256_hex, secret: "${GITHUB_WEBHOOK_SECRET}", signature_header: X-Hub-Signature-256}
      - {id: env, type: envelope, description: "Builds the JSON envelope for the verified request.", body_format: json}
      - {id: ok, type: respond, description: "Normal path: acknowledge with 200.", status: 200, body: ok}
      - {id: na, type: no_auth, description: "Error path: marks the request verified so envelope can run."}
      - {id: env2, type: envelope, description: "Error path: envelope needed before respond can be reached.", body_format: json}
      - {id: unauth, type: respond, description: "Error path: an explicit 401 for the bad signature.", status: 401, body: "unauthorized"}
    edges: [in>body, body>auth, auth>env, env>ok, auth:on_error>na, na>env2, env2>unauth]

  # Envelope parse failure: on_error carries Verified. A second envelope with
  # body_format: raw always succeeds, then respond 400.
  parse_or_400:
    nodes:
      - {id: in, type: http_trigger, description: "Endpoint expecting a JSON payload.", path: /webhooks/json, methods: [POST]}
      - {id: body, type: read_body, description: "Reads the raw body, rejecting anything over 1 MiB.", max_bytes: 1048576}
      - {id: auth, type: no_auth, description: "No signature to check on this endpoint."}
      - {id: env, type: envelope, description: "Parses JSON; malformed bodies take the error port.", body_format: json}
      - {id: ok, type: respond, description: "Normal path: acknowledge with 200.", status: 200, body: ok}
      - {id: raw, type: envelope, description: "Error path: raw format always parses, so respond is reachable.", body_format: raw}
      - {id: bad, type: respond, description: "Error path: an explicit 400 for the malformed body.", status: 400, body: "invalid json"}
    edges: [in>body, body>auth, auth>env, env>ok, env:on_error>raw, raw>bad]