Skip to content

Commit 34ae940

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(webapp,webhook-engine,redis): recover webhook ingest when a Redis cluster primary goes silent
Webhook ingest recovers within seconds when a webhooks Redis cluster primary fails over. The cluster client refreshes its slot map on an interval, times out commands to a node that stopped answering (so ingest returns 503 with `Retry-After` instead of hanging), and verifies TLS against the configured host for nodes addressed by IP. Mono-RevId: 5464ec3e301de8602ffa833b37c891ade0851074
1 parent 89431c7 commit 34ae940

4 files changed

Lines changed: 22 additions & 7 deletions

File tree

‎apps/webapp/app/v3/webhookEngine.server.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,25 +27,33 @@ import { meter, tracer } from "./tracer.server";
2727

2828
export const webhookEngine = singleton("WebhookEngine", createWebhookEngine);
2929

30-
/** The webhooks Redis cluster, seeded from one node; ioredis discovers the rest. */
30+
/**
31+
* The webhooks Redis cluster, seeded from one node; ioredis discovers the rest. A primary that stops
32+
* answering without closing its sockets (an ElastiCache failover reboots the old one) never replies
33+
* MOVED, so the slot map is refreshed on an interval to find the promoted replica, and a command
34+
* waiting on the silent node times out instead of holding an ingest request. Redirects can name a
35+
* node by IP, which AWS certificates don't cover, so TLS checks every node against the seed host.
36+
*/
3137
function webhookRedisConnection(): WebhookRedisOptions {
38+
const host = env.WEBHOOK_REDIS_HOST ?? "localhost";
3239
const redisOptions = {
3340
username: env.WEBHOOK_REDIS_USERNAME,
3441
password: env.WEBHOOK_REDIS_PASSWORD,
3542
enableAutoPipelining: true,
36-
...(env.WEBHOOK_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
43+
commandTimeout: 3_000,
44+
connectTimeout: 3_000,
45+
...(env.WEBHOOK_REDIS_TLS_DISABLED === "true" ? {} : { tls: { servername: host } }),
3746
};
3847
return {
3948
cluster: {
40-
nodes: [
41-
{ host: env.WEBHOOK_REDIS_HOST ?? "localhost", port: env.WEBHOOK_REDIS_PORT ?? 6379 },
42-
],
49+
nodes: [{ host, port: env.WEBHOOK_REDIS_PORT ?? 6379 }],
4350
redisOptions,
4451
clusterOptions: {
4552
keyPrefix: "webhook:",
4653
dnsLookup: (address: string, callback: (err: Error | null, address: string) => void) =>
4754
callback(null, address),
48-
slotsRefreshTimeout: 10_000,
55+
slotsRefreshInterval: 2_000,
56+
slotsRefreshTimeout: 2_000,
4957
},
5058
},
5159
};

‎internal-packages/redis/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,11 @@ export function createRedisClusterClient(
141141
}
142142
});
143143

144+
// A failed slot refresh only surfaces as ClusterAllFailedError; each node's own failure, and
145+
// which node it was, arrives here.
146+
client.on("node error", (error: Error, node: string) => {
147+
logger.warn(`Redis cluster node error:`, { node, error: error.message });
148+
});
149+
144150
return client;
145151
}

‎internal-packages/webhook-engine/src/engine/errors.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ describe("isInfrastructureError", () => {
1919
expect(isInfrastructureError(Object.assign(new Error("x"), { code: "ECONNRESET" }))).toBe(true);
2020
expect(isInfrastructureError(new Error("CLUSTERDOWN The cluster is down"))).toBe(true);
2121
expect(isInfrastructureError(new Error("Connection is closed."))).toBe(true);
22+
expect(isInfrastructureError(new Error("Command timed out"))).toBe(true);
2223
expect(isInfrastructureError(new Error("None of startup nodes is available"))).toBe(true);
2324
expect(
2425
isInfrastructureError(

‎internal-packages/webhook-engine/src/engine/errors.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const NETWORK_ERROR_CODES = new Set([
3636
* redirections. Last error: <Name>: <message>`, so that wrapper counts too.
3737
*/
3838
const REDIS_UNAVAILABLE =
39-
/^(?:Too many Cluster redirections\. Last error: \w*Error: )?(?:(?:CLUSTERDOWN|TRYAGAIN|LOADING|READONLY|MASTERDOWN|OOM|MOVED|ASK)\b.*|Connection is closed\.|Stream isn't writeable and enableOfflineQueue options is false|Failed to refresh slots cache\.|None of startup nodes is available)$/s;
39+
/^(?:Too many Cluster redirections\. Last error: \w*Error: )?(?:(?:CLUSTERDOWN|TRYAGAIN|LOADING|READONLY|MASTERDOWN|OOM|MOVED|ASK)\b.*|Connection is closed\.|Command timed out|Stream isn't writeable and enableOfflineQueue options is false|Failed to refresh slots cache\.|None of startup nodes is available)$/s;
4040

4141
/**
4242
* Whether an error, or anything in its cause chain, is an infrastructure failure. Everything else,

0 commit comments

Comments
 (0)