15 Sept 2026 · incident: SEV-2-2026-09-15

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.

Started2026-09-15 09:41 UTC
Resolved2026-09-15 10:15 UTC
Impact~9% of edge requests returned 5xx for 34 minutes (one replica of two)
SeveritySEV-2
Systemsedge 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 a retry-after header 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 against api.nervly.io/v1/health stays 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

  1. 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.
  2. Readiness gates the subscription. A replica with a disconnected Redis reports degraded on /v1/health, and Caddy’s max_fails pulls it from rotation within seconds. A half-broken replica no longer serves half-broken traffic; it serves none.
  3. 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 why alerts.yaml insists 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

ItemOwnerStatus
Bounded, jittered Redis reconnect backoffgatewayShipped 2026-09-15
Readiness gate: degraded Redis pulls the replica from rotationgatewayShipped 2026-09-16
Per-replica checks on the status pageinfraShipped 2026-09-16
Chaos drill: sentinel failover with the fix in place, monthlyinfraScheduled

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.