|
| 1 | +# WebSocket Relay Service |
| 2 | + |
| 3 | +Covers issues **#1454** (automated retry with exponential backoff) and |
| 4 | +**#1453** (Prometheus alert metrics and health telemetry). |
| 5 | + |
| 6 | +## 1. Layout |
| 7 | + |
| 8 | +| File | Role | |
| 9 | +|---|---| |
| 10 | +| `src/lib/websocket-relay-security.js` | Origin, size, JWT and message-shape checks | |
| 11 | +| `src/lib/websocket-relay-recovery.js` | Circuit breaker, dead letter queue, `reconnectWithBackoff` | |
| 12 | +| `src/lib/websocket-relay-retry.js` | Retry policy, `deliverRelayMessage`, `redriveDeadLetters` (#1454) | |
| 13 | +| `src/lib/websocket-relay-metrics.js` | Granular relay counters (#1083) | |
| 14 | +| `src/lib/websocket-relay-telemetry.js` | Alert metrics and rolling-window health (#1453) | |
| 15 | +| `docs/alerts/websocket-relay.rules.yml` | Prometheus alert rules | |
| 16 | + |
| 17 | +## 2. Retry with exponential backoff (#1454) |
| 18 | + |
| 19 | +```js |
| 20 | +import { deliverRelayMessage, redriveDeadLetters, withRelayRetry } from "./lib/websocket-relay-retry.js"; |
| 21 | +import { CircuitBreaker, DeadLetterQueue } from "./lib/websocket-relay-recovery.js"; |
| 22 | +import { attachRelayHealthSource } from "./lib/websocket-relay-telemetry.js"; |
| 23 | + |
| 24 | +const circuitBreaker = new CircuitBreaker(); |
| 25 | +const dlq = new DeadLetterQueue(); |
| 26 | +attachRelayHealthSource({ circuitBreaker, dlq }); |
| 27 | + |
| 28 | +// Deliver, retrying transient failures, dead-lettering what still fails. |
| 29 | +await deliverRelayMessage(message, (msg) => socket.sendAsync(msg), { circuitBreaker, dlq }); |
| 30 | + |
| 31 | +// Reconnect with the same policy. |
| 32 | +await withRelayRetry(() => connect(), { operation: "reconnect", signal }); |
| 33 | + |
| 34 | +// Later, once the downstream recovers: |
| 35 | +await redriveDeadLetters(dlq, (msg) => socket.sendAsync(msg), { limit: 100 }); |
| 36 | +``` |
| 37 | + |
| 38 | +- **Delay:** full jitter, `random(0, min(maxDelayMs, baseDelayMs * 2^n))`. |
| 39 | +- **Retried:** network error codes (`ECONNRESET`, `ETIMEDOUT`, ...), timeouts, |
| 40 | + HTTP 408/429/5xx (not 501), WebSocket close codes 1006/1011/1012/1013, and |
| 41 | + errors flagged `retryable: true`. |
| 42 | +- **Not retried:** an OPEN circuit breaker (`circuitOpen`), other 4xx |
| 43 | + (validation, signature, auth), policy close codes, `retryable: false`, |
| 44 | + and programming errors. These fail or dead-letter immediately. |
| 45 | +- **Bounds:** `maxAttempts`, a total `maxElapsedMs` budget including waits, |
| 46 | + and an optional `AbortSignal`. |
| 47 | +- **Failures** carry `retryAttempts`. With a `dlq`, `deliverRelayMessage` |
| 48 | + dead-letters the message (with the total retry count) instead of throwing. |
| 49 | +- **Logs** contain the operation label, attempt counts, delay and the |
| 50 | + error's code/status/close code, with the message truncated to 200 |
| 51 | + characters. Payloads are never logged. |
| 52 | + |
| 53 | +| Variable | Default | Range | |
| 54 | +|---|---|---| |
| 55 | +| `RELAY_RETRY_MAX_ATTEMPTS` | `5` | 1-10 | |
| 56 | +| `RELAY_RETRY_BASE_DELAY_MS` | `250` | 0-60000 | |
| 57 | +| `RELAY_RETRY_MAX_DELAY_MS` | `10000` | base-60000 | |
| 58 | +| `RELAY_RETRY_MAX_ELAPSED_MS` | `30000` | 0-300000 | |
| 59 | + |
| 60 | +`reconnectWithBackoff` in `websocket-relay-recovery.js` is unchanged. |
| 61 | + |
| 62 | +## 3. Alert metrics and health (#1453) |
| 63 | + |
| 64 | +Exposed on `GET /metrics` (merged from their own registry, together with the |
| 65 | +existing `relay_*` series, which were not previously scraped). |
| 66 | + |
| 67 | +| Metric | Type | Labels | |
| 68 | +|---|---|---| |
| 69 | +| `websocket_relay_deliveries_total` | counter | `outcome`: delivered, recovered, dead_lettered, failed | |
| 70 | +| `websocket_relay_retries_total` | counter | `operation`: deliver, reconnect, redrive, other; `result`: scheduled, recovered, exhausted, aborted | |
| 71 | +| `websocket_relay_retry_delay_seconds` | histogram | `operation` | |
| 72 | +| `websocket_relay_health_state` | gauge | 0 healthy, 1 degraded, 2 unhealthy | |
| 73 | +| `websocket_relay_delivery_failure_ratio` | gauge | rolling window | |
| 74 | +| `websocket_relay_dlq_depth` | gauge | live DLQ size | |
| 75 | +| `websocket_relay_circuit_open` | gauge | 1 OPEN, 0.5 HALF_OPEN, 0 CLOSED | |
| 76 | +| `websocket_relay_last_delivery_timestamp_seconds` | gauge | | |
| 77 | + |
| 78 | +Gauges are computed at scrape time from a fixed ring of 5-second buckets, so |
| 79 | +they decay when traffic stops and memory stays constant. |
| 80 | + |
| 81 | +`GET /health/websocket-relay` returns the snapshot (no payloads or payment |
| 82 | +ids). Status rules: |
| 83 | + |
| 84 | +| Status | When | |
| 85 | +|---|---| |
| 86 | +| unhealthy (HTTP 503) | circuit OPEN, failure ratio >= `RELAY_FAILURE_RATIO_THRESHOLD` (default 0.1) with at least `RELAY_HEALTH_MIN_SAMPLES` (20) deliveries, or DLQ >= `RELAY_DLQ_UNHEALTHY_THRESHOLD` (100) | |
| 87 | +| degraded (HTTP 200) | circuit HALF_OPEN, DLQ >= `RELAY_DLQ_DEGRADED_THRESHOLD` (1), or retries exhausted in the window | |
| 88 | +| healthy | otherwise | |
| 89 | + |
| 90 | +The window is `RELAY_HEALTH_WINDOW_MS` (default 5 minutes). `GET /health` |
| 91 | +also reports `services.websocket_relay` (informational; it does not change |
| 92 | +the overall status code). |
| 93 | + |
| 94 | +## 4. Security notes |
| 95 | + |
| 96 | +- Label values come from fixed server-side sets; message fields, origins, |
| 97 | + payment ids and error text never become labels. |
| 98 | +- The health snapshot and logs contain counts, states and error codes only. |
| 99 | +- An OPEN breaker is never retried, so retries cannot defeat it. |
| 100 | +- Retry configuration is clamped, so a misconfigured environment cannot |
| 101 | + disable backoff or make a delivery wait for minutes. |
0 commit comments