cmd/gateway, internal/gateway). Every value
is a required environment key: the gateway refuses to start when one is missing,
so no limit runs at a value nobody chose. Change a value only with a recorded
reason in the table below.
Starting values
Other settings:
GATEWAY_NODE_ID (unique per instance), GATEWAY_ADDR (for caddy),
GATEWAY_OPS_ADDR (/healthz, /readyz, /metrics, private), DBIP_MMDB and
TRACKER_RELEASE_DIR (both baked into the image), and the NATS_* keys.
Fixed in code: publish acknowledgement wait 2 s (PublishAckWait), readiness canary
every 1 s with a maximum age of 7 s (CanaryMaxAge, so /readyz fails within 10 s
of a queue outage), 200,000 entries each for the replay table, the ip-cluster sets,
the visitor volume table and each rate limit table.
Each limit is per node. With three edge nodes, a site can send three times the
site rate, and a captured proof can be replayed three times the replay limit.
Acceptance criterion, met on 2026-11-12: no event of the phase 3 and phase 4
browser suites was refused, rate limited or quarantined against the real gateway
(TestBrowserMatrix in tests/gateway).
Responses
202 queued (only after the JetStream acknowledgement), 204 dropped forSec-GPC: 1,
400 malformed (also a missing User-Agent), 403 origin, challenge, proof, replay or
fetch metadata, 404 unknown or inactive site, 410 revoked tracker version (the file,
and an event whose sv names a revoked release), 413 body too large, 429 rate limited (events and challenges), 503 queue unavailable, registry
not loaded or a missing client address header (events and challenges). Every response to a request with Origin carries
Access-Control-Allow-Origin with that origin and Vary: Origin. The tracker files
carry Access-Control-Allow-Origin: *, Cross-Origin-Resource-Policy: cross-origin
and Cache-Control: public, max-age=31536000, immutable; there is no latest alias.
Readiness
/readyz checks, in this order: a JetStream acknowledgement within 7 s, that
edge_secrets is not known to be missing, the registry snapshot, today’s proof key,
today’s daily secrets. The canary is a create of a
short lived key (canary.<node>.<ns>, ttl 30 s) in edge_secrets, not a message on
WEB_EVENTS: a work-queue message would reach the replayer as an event. An outage of
WEB_EVENTS alone therefore does not show in /readyz; event publishes then answer
503 directly.
A restart of a single NATS server loses the memory buckets (edge_secrets,
visit_state, locks) entirely, not just their keys; a full restart of the 3-node
cluster keeps their definitions but empties them. Each service recreates the buckets it
uses itself, with exactly the topology config from internal/natsx: the gateway
edge_secrets, the replayer visit_state and locks, jobs and the metricswriter
locks. They check at
startup, every second, and at once when a bucket operation fails as a missing stream
would. Their NATS users may create exactly those KV_<bucket> streams and nothing
else. No operator step is needed (tests/recovery, build tag recoveryit).
An emptied edge_secrets is the accepted trade-off: the day’s secrets and proof key are
new, so visitors seen earlier that day are counted again and challenges issued before
the restart stop verifying once the 10 s key refresh ran. Every gateway compares its
loaded daily secrets with the bucket every 10 s and switches to the stored ones, so a
gateway that kept running agrees with one started after the restart. Secrets are never
persisted.
Measured 2026-11-12 (tests/recovery), times after NATS was healthy again:
Throughput
Measured 2026-11-12 on the Office host (Intel Core Ultra 5 225, 10 cores), one gateway, one NATS server with file storage, all in Docker on one machine. Every request is a fullPOST /v1/e: parse, proof, origin, rate limits, visitor token,
country, ip-cluster, sanitization and a JetStream publish waited for. Traffic is
spread over 20 sites and random 10.0.0.0/8 client addresses so the limiter does not
cap it.
Target: at least 2,000 req/s with p99 under 50 ms on one instance. Met by both.
make gateway-load is a manual target and not part of make gateway-it or the nightly e2e
workflow. On GitHub’s standard runner for private repositories (2 vCPUs, 7 GB) k6, the gateway and
NATS share two cores; two runs there sent 2,400 and 2,221 req/s with p99 174 ms and 222 ms
(e2e runs 43437576423 and 43454017432), which measures the runner, not the gateway. Run it on a host
with cores to spare for k6 before a release that touches the event path. The production edge VM
(Standard_B2als_v2, 2 vCPUs) has not been measured with this test.
Under saturation (160 concurrent clients) the in-process node reached 7,721 req/s
with p99 79.8 ms, so latency at the target rate comes from queueing, not the path.
Dropped stats
Thejobs edgestats task needs a site’s tenant from the site registry. If an
EDGE_STATS counter names a site that is still not in the registry after
every retry (about 36 seconds), that counter is dropped and logged at error level. The
jobs_edgestats_dropped_total metric counts the dropped site counts and
reports 0 from startup. jobs_edgestats_unattributed_total{outcome} breaks
the drops down by outcome. The other counters in the message are still
stored.