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.
| Key | Type | Default | Description |
|---|---|---|---|
rules | array | required, non-empty | Evaluated in order; first match wins. |
rules[].case | array | match all | Triple-array condition, rules AND-ed (e.g. [["uri", "~~", "^/admin"], ["arg_debug", "==", "1"]]). Omit to match every request. |
rules[].actions | array | required, non-empty | [[name, params]]. Only the first action is applied. |
return action params
| Key | Type | Default | Description |
|---|---|---|---|
code | integer 100–599 | required | Rejection status. Body is always {"error_msg":"rejected by workflow"}. |
limit-count action params
| Key | Type | Default | Description |
|---|---|---|---|
count | integer > 0 | required | Allowed requests per window. |
time_window | number > 0 (seconds) | required | Fixed window length. |
key | string | "$remote_addr" | $var template resolved per request into the counter key (e.g. "$http_x_api_key", "$remote_addr:$uri"). |
rejected_code | integer 200–599 | 503 | Status when the limit is exceeded. |
rejected_msg | string | — | When set, the rejection body is {"error_msg": "<msg>"}; empty body otherwise. |
policy | string | "local" | Counter backend. local = per-instance in-memory windows; redis = cluster-shared windows via a named stores: entry. |
store | string | — | Required 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.
returnmatches — the plugin writes the rejection ontocontext.response(configured status, JSON body{"error_msg":"rejected by workflow"},content-type: application/json) and exits through thedeniedport.limit-count, within limit — the plugin setsx-ratelimit-limit/x-ratelimit-remaining/x-ratelimit-resetresponse headers and continues through thesuccessport.limit-count, exceeded — rejection written ontocontext.response(statusrejected_code, quota headers, body fromrejected_msgor empty) and exits through thelimitedport.limit-count, counter backend failure — a genuine infrastructure failure; fails with error codeRATE_LIMIT_ERRORthrough theerrorport. With thelocalpolicy this is rare (the in-memory backend is effectively infallible); withpolicy: redisa backend error always exits througherror— the workflowlimit-countaction has noallow_degradationfail-open option, unlike the standalonelimit-countplugin.- 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
returnandlimit-countactions are supported. limit-count'skeyis a$vartemplate (default"$remote_addr"); there is no separatekey_type.limit-count'sredispolicy has noallow_degradationfail-open option (unlike the standalonelimit-countplugin) — a backend error always rejects through theerrorport.- 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.
| Code | Status | When |
|---|---|---|
RATE_LIMIT_ERROR | 500 | A limit-count action's counter backend failed — the rule rejects rather than failing open. |