Elevated 5xx at the edge (2026-09-15)
Summary
For 34 minutes on 15 September 2026, one of the two edge-gateway replicas
returned intermittent 5xx responses. The trigger was unremarkable: the
Redis primary failed over during a routine upgrade, as it is designed to.
The response was not: the gateway’s Redis client reconnected in an unbounded
tight loop, saturated a worker-thread pool, and made every rate-limit lookup
on that replica time out until the replica was restarted. The public impact
was roughly half of a half, about 9% of requests over the window, because
Caddy’s round-robin load balancing kept steering every other request onto
the healthy replica. Nobody’s message was lost; some requests needed a
client-side retry.
| Started | 2026-09-15 09:41 UTC |
| Resolved | 2026-09-15 10:15 UTC |
| Impact | ~9% of edge requests returned 5xx for 34 minutes (one replica of two) |
| Severity | SEV-2 |
| Systems | edge gateway, Redis |
What customers saw
The fleet never went dark, which made this incident quieter than SEV-2s usually look. Two failure signatures were visible from the outside:
- Intermittent
503s with aretry-afterheader on/v1/events, roughly every other request for the duration of the window. - Slightly elevated p99 enqueue latency while the healthy replica absorbed the stranded traffic (the benchmark ceiling of 50ms was never breached).
Delivery was not affected. Nothing sat in a dead letter queue, nothing was lost, and no subscriber-visible behavior changed. This was an availability defect at the ingest edge, and a small one at that, but it was a defect we had shipped ourselves, in code written to survive exactly this event.
Timeline (UTC)
- 08:50: Planned Redis upgrade begins; primary fails over. Sentinel promotes a replica within 12 seconds. Nothing in this part deviated from the drill we ran six weeks ago.
- 09:41: The gateway’s Redis client begins its reconnect loop. The loop has no jitter and no cap: each failure schedules the next attempt immediately.
- 09:43: The reconnect loop saturates the replica’s blocking
worker-thread pool. Rate-limit lookups and idempotency-lock reads start
timing out; requests surface as
503s. The unhealthy period has begun. - 09:52: Alert fires:
Gateway (replica 2)degraded on the status page. The external synthetic monitor againstapi.nervly.io/v1/healthstays green the whole time; Caddy keeps balancing onto the healthy replica, so the fleet-level probe never trips. - 09:54: On-call identifies the reconnect storm from logs: tens of thousands of connection attempts in nine minutes, all against a primary that is now healthy and unfazed.
- 10:05: Hotfix prepared: bounded exponential backoff with full jitter.
- 10:15: Replica restarted with the fix. Error rate to zero. Incident closed ten minutes later after a clean soak.
Root cause
The gateway treats Redis as optional-but-fast: rate-limit counters and idempotency locks live there, and a failed lookup degrades to a permissive answer rather than failing the request. That degradation path is correct. What was broken was everything before it.
The reconnect policy was fail → immediately retry, forever. Redis
failovers take seconds; a capped, jittered loop would have reconnected
within five seconds and nobody would have noticed anything. Instead the
tight loop held a full core spinning and, because the Go Redis client
shares a connection pool whose construction contends on the same mutexes
the request path needs, starved the blocking pool that serves lookups.
The lookups slowed to the point where the degradation path itself exhausted: requests were queued behind reconnect attempts for longer than
their own deadline, and the permissive fallback could not save them.
In short: a failure of pacing, not of architecture. Every individual design decision (in-memory degradation, separate pools, health endpoints) was sound. The missing piece was a bound on how hard we try to reconnect.
Why it took 34 minutes
Half of that window was detection and diagnosis: the external monitor was green (by design it probes the fleet, not the units), so the first signal was the per-replica status check firing at 09:52. The rest was the hotfix and restart. The post-incident fix list exists because each of those minutes had a name.
What we changed
- Bounded reconnect backoff. Exponential backoff with full jitter, capped at 5s. The tight loop is gone; reconnection after a failover now completes within seconds, invisibly.
- Readiness gates the subscription. A replica with a disconnected
Redis reports degraded on
/v1/health, and Caddy’smax_failspulls it from rotation within seconds. A half-broken replica no longer serves half-broken traffic; it serves none. - Per-replica status checks. The status page checks each gateway
replica individually (
Gateway (replica 1/2)), so a single dead replica is visible even while the edge still answers. The fleet-level probe remains the external monitor’s job; this incident is precisely whyalerts.yamlinsists those are different jobs.
What went well
- No data loss anywhere: the queue semantics held under partial failure.
- The replica itself never crashed or served wrong answers: every 5xx was honest, retryable, and header-annotated.
- Detection-to-diagnosis took two minutes once the right probe fired.
Where we got lucky
The load balancer masking the incident is a double-edged property. It kept customers whole this time; it also kept the fleet-level signal blind for eleven minutes. If the same reconnect storm had hit both replicas (a coordinated failover, for instance), the masking would have been a blackout.
Action items
| Item | Owner | Status |
|---|---|---|
| Bounded, jittered Redis reconnect backoff | gateway | Shipped 2026-09-15 |
| Readiness gate: degraded Redis pulls the replica from rotation | gateway | Shipped 2026-09-16 |
| Per-replica checks on the status page | infra | Shipped 2026-09-16 |
| Chaos drill: sentinel failover with the fix in place, monthly | infra | Scheduled |
The drill is the important one. Redis failover is not an edge case in this system; it is a recurring operation. An incident that can only happen once per upgrade cycle is not an incident we have fixed; it is one we have not yet repeated.