Setting quotas
GuideOperationsSince version 0.1.0
Operations guide: configure, deploy and observe the gateway. What the capability does, product side: product guide; calling it: developer guide.
Goal: limit per caller, not per address of the pod that relays, and keep a slow or dead upstream from becoming a general outage.
Command - a rate-limit policy, attached globally or per host:
policies:
definitions:
per-caller:
type: rate-limit
config:
key: caller # the token's subject, else the client's address
classes:
read: { rps: 50, burst: 100 } # GET, HEAD, OPTIONS
write: { rps: 10, burst: 20 } # everything else
chains:
global: [per-caller]
server:
trusted_proxies: [10.0.0.0/8] # the edge: its X-Forwarded-For is believed, nobody else'sBehind a cloud load balancer, the edge may not see the client either. A layer-4 load balancer connects to the edge from its own private address: every request, then, reaches the edge - and the gateway - from one address, and all anonymous callers share one bucket. Turn the proxy protocol on at both ends: the load balancer sends it (a Service annotation of the provider, for example
service.beta.kubernetes.io/scw-loadbalancer-proxy-protocol-v2: "*"on Scaleway orservice.beta.kubernetes.io/aws-load-balancer-proxy-protocol: "*"on AWS) and the edge's HTTPS listeners require it, which also stops a client from choosing its own address. With Envoy Gateway:apiVersion: gateway.envoyproxy.io/v1alpha1 kind: ClientTrafficPolicy metadata: { name: public-client, namespace: envoy-gateway-system } spec: targetRefs: - { group: gateway.networking.k8s.io, kind: Gateway, name: public, sectionName: https } proxyProtocol: {} # the load balancer sends it; a connection without it is refusedThe two settings go together: one without the other drops the traffic. Check the result in the edge's access log -
x-forwarded-formust show public addresses, not the load balancer's - and keepserver.trusted_proxieson the edge's range only.
An operation changes class in the contract - the class must exist in the policy, or the gateway refuses to start:
x-flexway-rate-class: writeA bucket per operation (1.0.0): by default (scope: class), a caller has one
bucket per class, which every operation of the class drains - a journey chaining three writes in the
same second spends three tokens of the same bucket. scope: operation gives each operation its own
bucket per caller, the class setting its size - what most gateways count. A class may
override the policy's scope; shared counters (server.counters) follow with nothing more to set.
config:
scope: operation # default: class
classes:
read: { rps: 50, burst: 100 }
write: { rps: 10, burst: 20 }
sensitive: { rps: 1, burst: 3, scope: class } # one bucket for all the sensitive operationsBehind a client server (1.0.0): a BFF relays anonymous users, all from its own address. The
header key counts each one apart, by a header the BFF sets - believed only when the peer proved
one of the trusted identities. Otherwise the header is ignored and the address stays the key: a
key a caller chooses is no key. An authenticated subject always stays the key.
policies:
definitions:
per-user:
type: rate-limit
config:
key: header
header: X-Client-Key
trusted: [web-bff.web.serviceaccount.identity.linkerd.cluster.local]
classes: { read: { rps: 20, burst: 40 } }
server:
trusted_proxies: [local] # the mesh proxy: the pod's own addresses
peer_identity_header: l5d-client-id # the mTLS identity it verifiedNot
127.0.0.1/32: the mesh proxy connects from the pod's IP, not from the loopback (measured with Linkerd).localnames the pod's own addresses, resolved at load - never a CIDR of pods, which would trust every pod. Behind a mesh, count anonymous callers by peer identity as above, never by address: the mesh does not cleanX-Forwarded-For.
The peer's identity comes from the certificate the gateway's own TLS listener verified, else - from a
peer of server.trusted_proxies only - from server.peer_identity_header when declared (and then
from it alone), else from x-forwarded-client-cert. Precondition of peer_identity_header: the local proxy overwrites
that header when a caller sends it; without that guarantee, any pod forges the key.
Validation and errors (1.0.0): the gateway validates parameters and body (full, the
default). A service that stays the authority on its body asks for params - the body is relayed
unread, neither validated nor bounded by max_body - or off - nothing is validated. No mode
opens the whitelist: an undeclared parameter is still refused. The errors' type points at your
portal:
hosts:
api.example.com: { default_upstream: orders, validation: params } # the whole host
errors:
type_base: https://docs.example.com/errors/ # type = <base><code>
field_codes: { minLength: too_short, minimum: out_of_range } # errors[].code in your own wordsEach entry of errors carries field, rule (for a human) and code, what a client
switches on: the JSON Schema keyword broken (required, type, enum, minLength, maximum,
additionalProperties...), or invalid when no schema could be applied (an unreadable body).
errors.field_codes renames a keyword to the token of your contract; a keyword the table does
not list keeps its name. A missing or extra property is named as the field (body.age), and every
broken rule has its own entry.
x-flexway-validation: full # in the contract: the operation wins over the hostPer upstream, the bulkhead and the circuit breaker (on by default, values shown are the defaults):
upstreams:
orders:
url: http://orders:8080
max_in_flight: 200 # no cap when absent
circuit_breaker: { failure_ratio: 0.5, min_requests: 20, window: 10s, cooldown: 5s }Check:
| Situation | Answer |
|---|---|
| Empty bucket | 429, Retry-After, RateLimit-Remaining: 0 - two users behind one server-side client have separate buckets |
| Any rate-limited response | RateLimit-Limit, -Remaining, -Reset |
| Body or parameter outside the schema | 422, the errors extension names each field and its code - under params, the body is no longer examined |
Method not declared on a declared path, QUERY or an extension token included |
405 with Allow |
Body past server.limits.max_body (1 MiB) |
413 |
| Upstream down, circuit open | 503 at once, Retry-After, Flexway-Degraded: circuit-open |
| Upstream saturated | 503 at once, Flexway-Degraded: upstream-saturated |
| Tenant quota spent | 429 quota_exceeded, Retry-After until midnight or the end of the month (UTC) |
| Gateway saturated | 503 overloaded on the sheddable first, Flexway-Degraded: shedding; critical never refused |
Share across replicas, sell plans, shed load (1.0.0) - without server.counters, counters are
local to each replica: three replicas give up to three times the rate. With it, each replica
decides alone and reconciles with the store every sync - no round trip on the path, a limit held
within one sync window:
server:
counters: { store: "${COUNTERS_URL}", sync: 1s } # Redis, Dragonfly or Valkey; not reloadable
shedding: { target: 20ms } # optional; not reloadable
in_flight: { max: 2048, queue: 0 } # optional - derived from the container's memory limit when absent; not reloadable
policies:
definitions:
plans:
type: quota # per tenant; needs server.counters
config: { limits: { day: 100000, month: 2000000 } }
chains:
global: [per-caller, plans] x-flexway-priority: sheddable # in the contract: critical | normal (default) | sheddableStore lost: the gateway serves, on local counters, and says so (flexway_counters_store_up at 0, a
WARN line). Shedding follows the Go runtime's scheduling latency - the gateway's own saturation,
not an upstream's slowness.