Skip to content

Commit f4f22da

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 f4f22da

5 files changed

Lines changed: 304 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: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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("defaults to isInfrastructureError when no isRetryable is provided", async () => {
87+
const infra = new Prisma.PrismaClientKnownRequestError("boom", {
88+
code: "P1001",
89+
clientVersion: "6.14.0",
90+
});
91+
const t = makeThunk(1, infra);
92+
// no isRetryable in the config — the module must default to the classifier
93+
const result = await withInfraRetry(t.run, { options, sleep: noSleep });
94+
expect(result).toBe("ok");
95+
expect(t.calls()).toBe(2);
96+
});
97+
98+
it("with the default classifier, does not retry a real query error", async () => {
99+
const queryError = new Prisma.PrismaClientKnownRequestError("not found", {
100+
code: "P2025",
101+
clientVersion: "6.14.0",
102+
});
103+
const t = makeThunk(Infinity, queryError);
104+
await expect(withInfraRetry(t.run, { options, sleep: noSleep })).rejects.toBe(queryError);
105+
expect(t.calls()).toBe(1);
106+
});
107+
108+
it("clamps a swapped min/max backoff", async () => {
109+
const t = makeThunk(1);
110+
const delays: number[] = [];
111+
await withInfraRetry(t.run, {
112+
options: { enabled: true, maxAttempts: 2, backoffMinMs: 300, backoffMaxMs: 100 },
113+
isRetryable: retryable,
114+
sleep: noSleep,
115+
random: () => 0.9,
116+
onRetry: ({ delayMs }) => delays.push(delayMs),
117+
});
118+
// min > max collapses to [100, 100], so the delay is 100 regardless of random
119+
expect(delays).toEqual([100]);
120+
});
121+
122+
it("computes a jittered delay within bounds and reports it", async () => {
123+
const t = makeThunk(1);
124+
const delays: number[] = [];
125+
await withInfraRetry(t.run, {
126+
options: { enabled: true, maxAttempts: 3, backoffMinMs: 100, backoffMaxMs: 300 },
127+
isRetryable: retryable,
128+
sleep: noSleep,
129+
random: () => 0.5,
130+
onRetry: ({ delayMs }) => delays.push(delayMs),
131+
});
132+
expect(delays[0]).toBe(200);
133+
});
134+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 || config.options.maxAttempts <= 1) {
45+
return run();
46+
}
47+
48+
const { maxAttempts, backoffMinMs, backoffMaxMs } = config.options;
49+
const budget = config.budget ?? UNLIMITED_RETRY_BUDGET;
50+
const isRetryable = config.isRetryable ?? isInfrastructureError;
51+
const sleep = config.sleep ?? defaultSleep;
52+
const random = config.random ?? Math.random;
53+
54+
let lastError: unknown;
55+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
56+
try {
57+
return await run();
58+
} catch (error) {
59+
lastError = error;
60+
if (attempt >= maxAttempts || !isRetryable(error) || !budget.tryConsume()) {
61+
throw error;
62+
}
63+
const low = Math.max(0, Math.min(backoffMinMs, backoffMaxMs));
64+
const high = Math.max(low, backoffMaxMs);
65+
const delayMs = Math.round(low + random() * (high - low));
66+
config.onRetry?.({ attempt, delayMs, error });
67+
await sleep(delayMs);
68+
}
69+
}
70+
71+
// Unreachable: the final attempt always returns or throws above. Present so the
72+
// function is statically known to return `R` or throw on every path.
73+
throw lastError;
74+
}

0 commit comments

Comments
 (0)