Skip to content

Commit e77d850

Browse files
committed
feat(database): add infra-error classifier and read-retry util
Adds a shared classifier (isInfrastructureError / looksLikeConnectivityError) recognising connection-blip failures (P1001/P1002/P1008/P1017, ECONNRESET, "connection terminated", "server has closed the connection"), and withInfraRetry — a retry helper gated by an enabled kill-switch (default off) and the existing TokenBucketRetryBudget. Only for operations safe to run more than once (reads, or writes made idempotent); it never authorises retrying a bare non-idempotent write. TRI-13553
1 parent 43ecf15 commit e77d850

5 files changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from "../generated/prisma";
22
export * from "./boundedIn";
3+
export * from "./infraError";
4+
export * from "./infraRetry";
35
export * from "./transaction";
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Prisma } from "../generated/prisma";
3+
import { isInfrastructureError, looksLikeConnectivityError } from "./infraError";
4+
5+
const known = (code: string, message = "") =>
6+
new Prisma.PrismaClientKnownRequestError(message, { code, clientVersion: "6.14.0" });
7+
8+
describe("isInfrastructureError", () => {
9+
it("treats connection-level Prisma codes as infrastructure errors", () => {
10+
for (const code of ["P1001", "P1002", "P1008", "P1017"]) {
11+
expect(isInfrastructureError(known(code, "boom"))).toBe(true);
12+
}
13+
});
14+
15+
it("does not treat query/validation errors as infrastructure errors", () => {
16+
expect(isInfrastructureError(known("P2025", "record not found"))).toBe(false);
17+
expect(isInfrastructureError(known("P2002", "unique constraint"))).toBe(false);
18+
});
19+
20+
it("treats P2010 as infrastructure only when the message looks like connectivity loss", () => {
21+
expect(isInfrastructureError(known("P2010", "Connection terminated unexpectedly"))).toBe(true);
22+
expect(isInfrastructureError(known("P2010", "syntax error at or near"))).toBe(false);
23+
});
24+
25+
it("treats init / panic / unknown request errors as infrastructure errors", () => {
26+
expect(
27+
isInfrastructureError(new Prisma.PrismaClientInitializationError("no db", "6.14.0"))
28+
).toBe(true);
29+
});
30+
31+
it("recognises raw connectivity errno / messages", () => {
32+
expect(isInfrastructureError({ code: "ECONNRESET" })).toBe(true);
33+
expect(isInfrastructureError(new Error("server has closed the connection"))).toBe(true);
34+
expect(isInfrastructureError(new Error("column does not exist"))).toBe(false);
35+
});
36+
});
37+
38+
describe("looksLikeConnectivityError", () => {
39+
it("matches known errno codes and message fragments", () => {
40+
expect(looksLikeConnectivityError({ code: "EHOSTUNREACH" })).toBe(true);
41+
expect(looksLikeConnectivityError(new Error("Can't reach database server"))).toBe(true);
42+
expect(looksLikeConnectivityError(new Error("relation does not exist"))).toBe(false);
43+
});
44+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Prisma } from "../generated/prisma";
2+
3+
// Prisma connectivity / infrastructure error codes — connection-level failures,
4+
// not query- or validation-level ones (e.g. P1001 "Can't reach database server").
5+
const INFRASTRUCTURE_PRISMA_CODES = new Set(["P1001", "P1002", "P1008", "P1017"]);
6+
7+
const CONNECTIVITY_ERRNO = new Set([
8+
"ECONNREFUSED",
9+
"ENOTFOUND",
10+
"ETIMEDOUT",
11+
"ECONNRESET",
12+
"EHOSTUNREACH",
13+
"EPIPE",
14+
]);
15+
16+
const CONNECTIVITY_MESSAGE =
17+
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|database not reachable|can't reach database|connection terminated|server has closed the connection|timed out fetching a new connection/i;
18+
19+
/** True for an errno/message that looks like a lost or unreachable connection. */
20+
export function looksLikeConnectivityError(error: unknown): boolean {
21+
const e = error as { code?: unknown; message?: unknown };
22+
if (typeof e?.code === "string" && CONNECTIVITY_ERRNO.has(e.code)) {
23+
return true;
24+
}
25+
return typeof e?.message === "string" && CONNECTIVITY_MESSAGE.test(e.message);
26+
}
27+
28+
/**
29+
* True when `error` is a Prisma infrastructure/connectivity failure (DB
30+
* unreachable, timed out, connection dropped) rather than a query- or
31+
* validation-level error. This is the retry gate for connection blips.
32+
*/
33+
export function isInfrastructureError(error: unknown): boolean {
34+
if (
35+
error instanceof Prisma.PrismaClientInitializationError ||
36+
error instanceof Prisma.PrismaClientRustPanicError ||
37+
error instanceof Prisma.PrismaClientUnknownRequestError
38+
) {
39+
return true;
40+
}
41+
42+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
43+
if (INFRASTRUCTURE_PRISMA_CODES.has(error.code)) {
44+
return true;
45+
}
46+
return error.code === "P2010" && looksLikeConnectivityError(error);
47+
}
48+
49+
return looksLikeConnectivityError(error);
50+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Prisma } from "../generated/prisma";
3+
import { type RetryBudget } from "./transaction";
4+
import { withInfraRetry } from "./infraRetry";
5+
6+
const options = { enabled: true, maxAttempts: 4, backoffMinMs: 0, backoffMaxMs: 0 };
7+
const noSleep = () => Promise.resolve();
8+
const retryable = () => true;
9+
10+
// A thunk that throws `error` for its first `failTimes` calls, then resolves.
11+
function makeThunk(failTimes: number, error: unknown = new Error("infra"), value = "ok") {
12+
let calls = 0;
13+
return {
14+
run: async () => {
15+
calls++;
16+
if (calls <= failTimes) throw error;
17+
return value;
18+
},
19+
calls: () => calls,
20+
};
21+
}
22+
23+
describe("withInfraRetry", () => {
24+
it("runs the thunk exactly once when disabled", async () => {
25+
const t = makeThunk(Infinity);
26+
await expect(
27+
withInfraRetry(t.run, { options: { ...options, enabled: false }, isRetryable: retryable })
28+
).rejects.toThrow("infra");
29+
expect(t.calls()).toBe(1);
30+
});
31+
32+
it("retries a retryable error until it succeeds", async () => {
33+
const t = makeThunk(2);
34+
const result = await withInfraRetry(t.run, { options, isRetryable: retryable, sleep: noSleep });
35+
expect(result).toBe("ok");
36+
expect(t.calls()).toBe(3);
37+
});
38+
39+
it("does not retry a non-retryable error", async () => {
40+
const t = makeThunk(Infinity, new Error("query bug"));
41+
await expect(
42+
withInfraRetry(t.run, { options, isRetryable: () => false, sleep: noSleep })
43+
).rejects.toThrow("query bug");
44+
expect(t.calls()).toBe(1);
45+
});
46+
47+
it("gives up after maxAttempts", async () => {
48+
const t = makeThunk(Infinity);
49+
await expect(
50+
withInfraRetry(t.run, { options, isRetryable: retryable, sleep: noSleep })
51+
).rejects.toThrow("infra");
52+
expect(t.calls()).toBe(4);
53+
});
54+
55+
it("stops retrying once the shared budget is exhausted", async () => {
56+
const t = makeThunk(Infinity);
57+
let tokens = 1;
58+
const budget: RetryBudget = {
59+
tryConsume: () => {
60+
if (tokens > 0) {
61+
tokens--;
62+
return true;
63+
}
64+
return false;
65+
},
66+
};
67+
await expect(
68+
withInfraRetry(t.run, { options, isRetryable: retryable, budget, sleep: noSleep })
69+
).rejects.toThrow("infra");
70+
// first attempt + one budgeted retry, then the budget denies the next
71+
expect(t.calls()).toBe(2);
72+
});
73+
74+
it("runs the thunk exactly once when maxAttempts <= 1", async () => {
75+
const t = makeThunk(Infinity);
76+
await expect(
77+
withInfraRetry(t.run, {
78+
options: { ...options, maxAttempts: 1 },
79+
isRetryable: retryable,
80+
sleep: noSleep,
81+
})
82+
).rejects.toThrow("infra");
83+
expect(t.calls()).toBe(1);
84+
});
85+
86+
it("runs the thunk once (never skipped, never unbounded) for a non-finite maxAttempts", async () => {
87+
for (const maxAttempts of [NaN, Infinity]) {
88+
const t = makeThunk(Infinity);
89+
await expect(
90+
withInfraRetry(t.run, {
91+
options: { ...options, maxAttempts },
92+
isRetryable: retryable,
93+
sleep: noSleep,
94+
})
95+
).rejects.toThrow("infra");
96+
expect(t.calls()).toBe(1);
97+
}
98+
});
99+
100+
it("floors a fractional maxAttempts to whole attempts", async () => {
101+
const t = makeThunk(Infinity);
102+
await expect(
103+
withInfraRetry(t.run, {
104+
options: { ...options, maxAttempts: 3.9 },
105+
isRetryable: retryable,
106+
sleep: noSleep,
107+
})
108+
).rejects.toThrow("infra");
109+
expect(t.calls()).toBe(3);
110+
});
111+
112+
it("defaults to isInfrastructureError when no isRetryable is provided", async () => {
113+
const infra = new Prisma.PrismaClientKnownRequestError("boom", {
114+
code: "P1001",
115+
clientVersion: "6.14.0",
116+
});
117+
const t = makeThunk(1, infra);
118+
// no isRetryable in the config — the module must default to the classifier
119+
const result = await withInfraRetry(t.run, { options, sleep: noSleep });
120+
expect(result).toBe("ok");
121+
expect(t.calls()).toBe(2);
122+
});
123+
124+
it("with the default classifier, does not retry a real query error", async () => {
125+
const queryError = new Prisma.PrismaClientKnownRequestError("not found", {
126+
code: "P2025",
127+
clientVersion: "6.14.0",
128+
});
129+
const t = makeThunk(Infinity, queryError);
130+
await expect(withInfraRetry(t.run, { options, sleep: noSleep })).rejects.toBe(queryError);
131+
expect(t.calls()).toBe(1);
132+
});
133+
134+
it("clamps a swapped min/max backoff", async () => {
135+
const t = makeThunk(1);
136+
const delays: number[] = [];
137+
await withInfraRetry(t.run, {
138+
options: { enabled: true, maxAttempts: 2, backoffMinMs: 300, backoffMaxMs: 100 },
139+
isRetryable: retryable,
140+
sleep: noSleep,
141+
random: () => 0.9,
142+
onRetry: ({ delayMs }) => delays.push(delayMs),
143+
});
144+
// min > max collapses to [100, 100], so the delay is 100 regardless of random
145+
expect(delays).toEqual([100]);
146+
});
147+
148+
it("computes a jittered delay within bounds and reports it", async () => {
149+
const t = makeThunk(1);
150+
const delays: number[] = [];
151+
await withInfraRetry(t.run, {
152+
options: { enabled: true, maxAttempts: 3, backoffMinMs: 100, backoffMaxMs: 300 },
153+
isRetryable: retryable,
154+
sleep: noSleep,
155+
random: () => 0.5,
156+
onRetry: ({ delayMs }) => delays.push(delayMs),
157+
});
158+
expect(delays[0]).toBe(200);
159+
});
160+
161+
it("keeps the delay finite and in range for bad backoff bounds and out-of-range random", async () => {
162+
// NaN backoff bound must not produce a NaN delay.
163+
const t1 = makeThunk(1);
164+
const nanDelays: number[] = [];
165+
await withInfraRetry(t1.run, {
166+
options: { enabled: true, maxAttempts: 2, backoffMinMs: 0, backoffMaxMs: NaN },
167+
isRetryable: retryable,
168+
sleep: noSleep,
169+
onRetry: ({ delayMs }) => nanDelays.push(delayMs),
170+
});
171+
expect(nanDelays[0]).toBe(0);
172+
173+
// random() above 1 clamps to the high bound.
174+
const t2 = makeThunk(1);
175+
const highDelays: number[] = [];
176+
await withInfraRetry(t2.run, {
177+
options: { enabled: true, maxAttempts: 2, backoffMinMs: 100, backoffMaxMs: 300 },
178+
isRetryable: retryable,
179+
sleep: noSleep,
180+
random: () => 2,
181+
onRetry: ({ delayMs }) => highDelays.push(delayMs),
182+
});
183+
expect(highDelays[0]).toBe(300);
184+
185+
// random() below 0 clamps to the low bound.
186+
const t3 = makeThunk(1);
187+
const lowDelays: number[] = [];
188+
await withInfraRetry(t3.run, {
189+
options: { enabled: true, maxAttempts: 2, backoffMinMs: 100, backoffMaxMs: 300 },
190+
isRetryable: retryable,
191+
sleep: noSleep,
192+
random: () => -1,
193+
onRetry: ({ delayMs }) => lowDelays.push(delayMs),
194+
});
195+
expect(lowDelays[0]).toBe(100);
196+
});
197+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { isInfrastructureError } from "./infraError";
2+
import { type RetryBudget, UNLIMITED_RETRY_BUDGET } from "./transaction";
3+
4+
/** Retry tuning for {@link withInfraRetry}. */
5+
export type InfraRetryOptions = {
6+
/** Kill switch. When false, the thunk runs exactly once. Default off at call sites. */
7+
enabled: boolean;
8+
/** Total attempts including the first. `1` (or less) disables retrying. */
9+
maxAttempts: number;
10+
/** Lower bound of the jittered backoff between attempts, in ms. */
11+
backoffMinMs: number;
12+
/** Upper bound of the jittered backoff between attempts, in ms. */
13+
backoffMaxMs: number;
14+
};
15+
16+
export type InfraRetryConfig = {
17+
options: InfraRetryOptions;
18+
/** Shared budget; defaults to {@link UNLIMITED_RETRY_BUDGET}. */
19+
budget?: RetryBudget;
20+
/** Predicate for a retryable error; defaults to {@link isInfrastructureError}. */
21+
isRetryable?: (error: unknown) => boolean;
22+
sleep?: (ms: number) => Promise<void>;
23+
random?: () => number;
24+
onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void;
25+
};
26+
27+
function defaultSleep(ms: number): Promise<void> {
28+
return new Promise((resolve) => setTimeout(resolve, ms));
29+
}
30+
31+
/**
32+
* Runs `run` and retries it on a connection-blip error ({@link isInfrastructureError}
33+
* by default), up to `maxAttempts` with jittered backoff, gated by a shared
34+
* `budget` so a mass freeze can't amplify into a retry storm. Any other error
35+
* (or an exhausted budget) rethrows immediately.
36+
*
37+
* ONLY wrap operations that are safe to run more than once: reads, or writes
38+
* that have been made idempotent. Never wrap a bare non-idempotent write.
39+
*/
40+
export async function withInfraRetry<R>(
41+
run: () => Promise<R>,
42+
config?: InfraRetryConfig
43+
): Promise<R> {
44+
if (!config || !config.options.enabled) {
45+
return run();
46+
}
47+
48+
const { backoffMinMs, backoffMaxMs } = config.options;
49+
// Bound the loop defensively: a non-finite maxAttempts (NaN/Infinity from a bad
50+
// env parse) or a value below 2 means "run once, no retry" — never a skipped run
51+
// or an unbounded loop. Fractional values floor to whole attempts.
52+
const maxAttempts = Number.isFinite(config.options.maxAttempts)
53+
? Math.floor(config.options.maxAttempts)
54+
: 1;
55+
if (maxAttempts <= 1) {
56+
return run();
57+
}
58+
59+
const budget = config.budget ?? UNLIMITED_RETRY_BUDGET;
60+
const isRetryable = config.isRetryable ?? isInfrastructureError;
61+
const sleep = config.sleep ?? defaultSleep;
62+
const random = config.random ?? Math.random;
63+
64+
let lastError: unknown;
65+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
66+
try {
67+
return await run();
68+
} catch (error) {
69+
lastError = error;
70+
if (attempt >= maxAttempts || !isRetryable(error) || !budget.tryConsume()) {
71+
throw error;
72+
}
73+
// Defend the delay math: non-finite bounds fall back to 0, and an injected
74+
// random() outside [0, 1] is clamped, so delayMs is always finite and within
75+
// [low, high]. A swapped min/max still collapses to the smaller bound.
76+
const min = Number.isFinite(backoffMinMs) ? backoffMinMs : 0;
77+
const max = Number.isFinite(backoffMaxMs) ? backoffMaxMs : min;
78+
const low = Math.max(0, Math.min(min, max));
79+
const high = Math.max(low, max);
80+
const r = Math.min(1, Math.max(0, random()));
81+
const delayMs = Math.round(low + r * (high - low));
82+
config.onRetry?.({ attempt, delayMs, error });
83+
await sleep(delayMs);
84+
}
85+
}
86+
87+
// Unreachable: the final attempt always returns or throws above. Present so the
88+
// function is statically known to return `R` or throw on every path.
89+
throw lastError;
90+
}

0 commit comments

Comments
 (0)