Skip to content

Commit f2e60b5

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 f2e60b5

5 files changed

Lines changed: 283 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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it, vi } 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+
describe("withInfraRetry", () => {
11+
it("runs the thunk exactly once when disabled", async () => {
12+
const run = vi.fn().mockRejectedValue(new Error("infra"));
13+
await expect(
14+
withInfraRetry(run, { options: { ...options, enabled: false }, isRetryable: retryable })
15+
).rejects.toThrow("infra");
16+
expect(run).toHaveBeenCalledTimes(1);
17+
});
18+
19+
it("retries a retryable error until it succeeds", async () => {
20+
const run = vi
21+
.fn()
22+
.mockRejectedValueOnce(new Error("infra"))
23+
.mockRejectedValueOnce(new Error("infra"))
24+
.mockResolvedValue("ok");
25+
const result = await withInfraRetry(run, { options, isRetryable: retryable, sleep: noSleep });
26+
expect(result).toBe("ok");
27+
expect(run).toHaveBeenCalledTimes(3);
28+
});
29+
30+
it("does not retry a non-retryable error", async () => {
31+
const run = vi.fn().mockRejectedValue(new Error("query bug"));
32+
await expect(
33+
withInfraRetry(run, { options, isRetryable: () => false, sleep: noSleep })
34+
).rejects.toThrow("query bug");
35+
expect(run).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it("gives up after maxAttempts", async () => {
39+
const run = vi.fn().mockRejectedValue(new Error("infra"));
40+
await expect(
41+
withInfraRetry(run, { options, isRetryable: retryable, sleep: noSleep })
42+
).rejects.toThrow("infra");
43+
expect(run).toHaveBeenCalledTimes(4);
44+
});
45+
46+
it("stops retrying once the shared budget is exhausted", async () => {
47+
const run = vi.fn().mockRejectedValue(new Error("infra"));
48+
let tokens = 1;
49+
const budget: RetryBudget = { tryConsume: () => (tokens-- > 0 ? true : false) };
50+
await expect(
51+
withInfraRetry(run, { options, isRetryable: retryable, budget, sleep: noSleep })
52+
).rejects.toThrow("infra");
53+
// first attempt + one budgeted retry, then the budget denies the next
54+
expect(run).toHaveBeenCalledTimes(2);
55+
});
56+
57+
it("runs the thunk exactly once when maxAttempts <= 1", async () => {
58+
const run = vi.fn().mockRejectedValue(new Error("infra"));
59+
await expect(
60+
withInfraRetry(run, {
61+
options: { ...options, maxAttempts: 1 },
62+
isRetryable: retryable,
63+
sleep: noSleep,
64+
})
65+
).rejects.toThrow("infra");
66+
expect(run).toHaveBeenCalledTimes(1);
67+
});
68+
69+
it("defaults to isInfrastructureError when no isRetryable is provided", async () => {
70+
const infra = new Prisma.PrismaClientKnownRequestError("boom", {
71+
code: "P1001",
72+
clientVersion: "6.14.0",
73+
});
74+
const run = vi.fn().mockRejectedValueOnce(infra).mockResolvedValue("ok");
75+
// no isRetryable in the config — the module must default to the classifier
76+
const result = await withInfraRetry(run, { options, sleep: noSleep });
77+
expect(result).toBe("ok");
78+
expect(run).toHaveBeenCalledTimes(2);
79+
});
80+
81+
it("with the default classifier, does not retry a real query error", async () => {
82+
const queryError = new Prisma.PrismaClientKnownRequestError("not found", {
83+
code: "P2025",
84+
clientVersion: "6.14.0",
85+
});
86+
const run = vi.fn().mockRejectedValue(queryError);
87+
await expect(withInfraRetry(run, { options, sleep: noSleep })).rejects.toBe(queryError);
88+
expect(run).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it("clamps a swapped min/max backoff", async () => {
92+
const run = vi.fn().mockRejectedValueOnce(new Error("infra")).mockResolvedValue("ok");
93+
const onRetry = vi.fn();
94+
await withInfraRetry(run, {
95+
options: { enabled: true, maxAttempts: 2, backoffMinMs: 300, backoffMaxMs: 100 },
96+
isRetryable: retryable,
97+
sleep: noSleep,
98+
random: () => 0.9,
99+
onRetry,
100+
});
101+
// min > max collapses to [100, 100], so the delay is 100 regardless of random
102+
expect(onRetry).toHaveBeenCalledWith(expect.objectContaining({ delayMs: 100 }));
103+
});
104+
105+
it("computes a jittered delay within bounds and reports it", async () => {
106+
const run = vi.fn().mockRejectedValueOnce(new Error("infra")).mockResolvedValue("ok");
107+
const onRetry = vi.fn();
108+
await withInfraRetry(run, {
109+
options: { enabled: true, maxAttempts: 3, backoffMinMs: 100, backoffMaxMs: 300 },
110+
isRetryable: retryable,
111+
sleep: noSleep,
112+
random: () => 0.5,
113+
onRetry,
114+
});
115+
expect(onRetry).toHaveBeenCalledWith(expect.objectContaining({ attempt: 1, delayMs: 200 }));
116+
});
117+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 attempt = 1;
55+
while (true) {
56+
try {
57+
return await run();
58+
} catch (error) {
59+
if (attempt >= maxAttempts || !isRetryable(error) || !budget.tryConsume()) {
60+
throw error;
61+
}
62+
const low = Math.max(0, Math.min(backoffMinMs, backoffMaxMs));
63+
const high = Math.max(low, backoffMaxMs);
64+
const delayMs = Math.round(low + random() * (high - low));
65+
config.onRetry?.({ attempt, delayMs, error });
66+
await sleep(delayMs);
67+
attempt += 1;
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)