Skip to main content

workflow

workflow

Evaluates ordered rules against each request; the first rule whose case matches applies its action. Supported actions: return (reject with a status code) and limit-count (fixed-window rate limiting via the shared counter store). Requests matching no rule pass through.

Configuration

All expressions and action parameters are validated at config load; unsupported action names are rejected.

KeyTypeDefaultDescription
rulesarrayrequired, non-emptyEvaluated in order; first match wins.
rules[].casearraymatch allTriple-array condition, rules AND-ed (e.g. [["uri", "~~", "^/admin"], ["arg_debug", "==", "1"]]). Omit to match every request.
rules[].actionsarrayrequired, non-empty[[name, params]]. Only the first action is applied.

return action params

KeyTypeDefaultDescription
codeinteger 100–599requiredRejection status. Body is always {"error_msg":"rejected by workflow"}.

limit-count action params

KeyTypeDefaultDescription
countinteger > 0requiredAllowed requests per window.
time_windownumber > 0 (seconds)requiredFixed window length.
keystring"$remote_addr"$var template resolved per request into the counter key (e.g. "$http_x_api_key", "$remote_addr:$uri").
rejected_codeinteger 200–599503Status when the limit is exceeded.
rejected_msgstringWhen set, the rejection body is {"error_msg": "<msg>"}; empty body otherwise.
policystring"local"Counter backend. local = per-instance in-memory windows; redis = cluster-shared windows via a named stores: entry.
storestringRequired when policy: redis: the name of a declared stores: entry (redis or valkey). Unknown names fail policy compilation.
type: workflow
config:
rules:
- case:
- ["uri", "~~", "^/admin"]
actions:
- ["return", { "code": 403 }]
- case:
- ["arg_tier", "==", "free"]
actions:
- ["limit-count", { "count": 100, "time_window": 60, "key": "$remote_addr", "rejected_code": 429 }]

Behavior

Rules are checked top to bottom; a rule without a case always matches. The first matching rule wins — its action applies and no further rules are evaluated.

  • return matches — the plugin writes the rejection onto context.response (configured status, JSON body {"error_msg":"rejected by workflow"}, content-type: application/json) and exits through the denied port.
  • limit-count, within limit — the plugin sets x-ratelimit-limit / x-ratelimit-remaining / x-ratelimit-reset response headers and continues through the success port.
  • limit-count, exceeded — rejection written onto context.response (status rejected_code, quota headers, body from rejected_msg or empty) and exits through the limited port.
  • limit-count, counter backend failure — a genuine infrastructure failure; fails with error code RATE_LIMIT_ERROR through the error port. With the local policy this is rare (the in-memory backend is effectively infallible); with policy: redis a backend error always exits through error — the workflow limit-count action has no allow_degradation fail-open option, unlike the standalone limit-count plugin.
  • No rule matches — passthrough on success, Context untouched.

Wiring the early exits

Rejections exit via denied or limited with the response already prepared — wire both ports to a pass-through path so the prepared rejection reaches the client:

edges:
- { from: workflow.success, to: upstream.in } # allowed traffic continues
- { from: workflow.denied, to: client.in } # 'return' rejection: prepared response goes out as-is
- { from: workflow.limited, to: client.in } # 'limit-count' rejection: prepared response goes out as-is
- { from: workflow.error, to: client.in } # counter-backend failure (rare)

Routing denied/limited through an error-handler will replace the prepared body with the handler's template.

Counters are isolated per workflow node instance and per rule. With the local policy they live in process memory and reset on restart/config reload. With policy: redis, counters are shared by every gateway instance via the named store, and window boundaries are wall-clock aligned (now / time_window) rather than first-request-aligned — switching from local changes when windows roll over. Backend errors are counted in gateway_counter_store_errors_total{store} and always exit through error (no allow_degradation).

Limitations

  • Only the return and limit-count actions are supported.
  • limit-count's key is a $var template (default "$remote_addr"); there is no separate key_type.
  • limit-count's redis policy has no allow_degradation fail-open option (unlike the standalone limit-count plugin) — a backend error always rejects through the error port.
  • Quota headers are always sent (not configurable).

Ports

workflow declares four output ports: success, denied (a return rule rejected the request), limited (a limit-count rule's quota was exceeded), and error (a genuine counter-backend failure). success, denied, and limited are mandatory — the policy compiler rejects any policy that leaves one unwired. See Wiring the early exits above.

Errors

The node returns the Context with an error, so the graph engine routes through the error port and appends the error to context.errors. The status below is the one prepared on context.response; wire error to client (or an error-handler) for the caller to see it.

CodeStatusWhen
RATE_LIMIT_ERROR500A limit-count action's counter backend failed — the rule rejects rather than failing open.