Skip to content

Commit d85831a

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 d85831a

5 files changed

Lines changed: 564 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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Prisma } from "../generated/prisma";
3+
import {
4+
isInfrastructureError,
5+
isRetryableInfrastructureError,
6+
looksLikeConnectivityError,
7+
} from "./infraError";
8+
9+
const known = (code: string, message = "") =>
10+
new Prisma.PrismaClientKnownRequestError(message, { code, clientVersion: "6.14.0" });
11+
12+
describe("isInfrastructureError", () => {
13+
it("treats connection-level Prisma codes as infrastructure errors", () => {
14+
for (const code of ["P1001", "P1002", "P1008", "P1017"]) {
15+
expect(isInfrastructureError(known(code, "boom"))).toBe(true);
16+
}
17+
});
18+
19+
it("does not treat query/validation errors as infrastructure errors", () => {
20+
expect(isInfrastructureError(known("P2025", "record not found"))).toBe(false);
21+
expect(isInfrastructureError(known("P2002", "unique constraint"))).toBe(false);
22+
});
23+
24+
it("treats P2010 as infrastructure only when the message looks like connectivity loss", () => {
25+
expect(isInfrastructureError(known("P2010", "Connection terminated unexpectedly"))).toBe(true);
26+
expect(isInfrastructureError(known("P2010", "syntax error at or near"))).toBe(false);
27+
});
28+
29+
it("treats init / panic / unknown request errors as infrastructure errors", () => {
30+
expect(
31+
isInfrastructureError(new Prisma.PrismaClientInitializationError("no db", "6.14.0"))
32+
).toBe(true);
33+
});
34+
35+
it("recognises raw connectivity errno / messages", () => {
36+
expect(isInfrastructureError({ code: "ECONNRESET" })).toBe(true);
37+
expect(isInfrastructureError(new Error("server has closed the connection"))).toBe(true);
38+
expect(isInfrastructureError(new Error("column does not exist"))).toBe(false);
39+
});
40+
});
41+
42+
describe("looksLikeConnectivityError", () => {
43+
it("matches known errno codes and message fragments", () => {
44+
expect(looksLikeConnectivityError({ code: "EHOSTUNREACH" })).toBe(true);
45+
expect(looksLikeConnectivityError(new Error("Can't reach database server"))).toBe(true);
46+
expect(looksLikeConnectivityError(new Error("relation does not exist"))).toBe(false);
47+
});
48+
});
49+
50+
describe("isRetryableInfrastructureError", () => {
51+
it("retries connection-level codes and connectivity errnos/messages", () => {
52+
for (const code of ["P1001", "P1002", "P1008", "P1017"]) {
53+
expect(isRetryableInfrastructureError(known(code, "boom"))).toBe(true);
54+
}
55+
expect(isRetryableInfrastructureError({ code: "ECONNRESET" })).toBe(true);
56+
expect(isRetryableInfrastructureError(new Error("server has closed the connection"))).toBe(
57+
true
58+
);
59+
});
60+
61+
it("does not retry query/validation errors", () => {
62+
expect(isRetryableInfrastructureError(known("P2025", "record not found"))).toBe(false);
63+
expect(isRetryableInfrastructureError(new Error("column does not exist"))).toBe(false);
64+
});
65+
66+
it("retries an init error only with a connectivity signal (not a permanent one)", () => {
67+
expect(
68+
isRetryableInfrastructureError(
69+
new Prisma.PrismaClientInitializationError("Can't reach database server", "6.14.0", "P1001")
70+
)
71+
).toBe(true);
72+
expect(
73+
isRetryableInfrastructureError(
74+
new Prisma.PrismaClientInitializationError(
75+
"Authentication failed against database server",
76+
"6.14.0",
77+
"P1000"
78+
)
79+
)
80+
).toBe(false);
81+
});
82+
83+
it("never retries a Rust-engine panic", () => {
84+
expect(
85+
isRetryableInfrastructureError(new Prisma.PrismaClientRustPanicError("panic", "6.14.0"))
86+
).toBe(false);
87+
});
88+
89+
it("never retries pool exhaustion (P2024), even though its message looks like connectivity", () => {
90+
const poolMsg = "Timed out fetching a new connection from the connection pool";
91+
expect(isRetryableInfrastructureError(known("P2024", poolMsg))).toBe(false);
92+
expect(isRetryableInfrastructureError(new Error(poolMsg))).toBe(false);
93+
// The broad classifier still flags it (used for logging, not retry).
94+
expect(isInfrastructureError(new Error(poolMsg))).toBe(true);
95+
});
96+
97+
it("retries an unknown-request error only with a connectivity signal", () => {
98+
expect(
99+
isRetryableInfrastructureError(
100+
new Prisma.PrismaClientUnknownRequestError("connection terminated unexpectedly", {
101+
clientVersion: "6.14.0",
102+
})
103+
)
104+
).toBe(true);
105+
expect(
106+
isRetryableInfrastructureError(
107+
new Prisma.PrismaClientUnknownRequestError("unexpected engine failure", {
108+
clientVersion: "6.14.0",
109+
})
110+
)
111+
).toBe(false);
112+
});
113+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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+
// Connection-pool exhaustion (P2024). Matched only to EXCLUDE it from retry:
20+
// retrying against an already-exhausted pool deepens the contention rather than
21+
// riding out a blip (the transaction-start retry gate excludes it for the same reason).
22+
const POOL_EXHAUSTION_MESSAGE = /timed out fetching a new connection/i;
23+
24+
/** True for an errno/message that looks like a lost or unreachable connection. */
25+
export function looksLikeConnectivityError(error: unknown): boolean {
26+
const e = error as { code?: unknown; message?: unknown };
27+
if (typeof e?.code === "string" && CONNECTIVITY_ERRNO.has(e.code)) {
28+
return true;
29+
}
30+
return typeof e?.message === "string" && CONNECTIVITY_MESSAGE.test(e.message);
31+
}
32+
33+
/**
34+
* True when `error` is a Prisma infrastructure/connectivity failure (DB
35+
* unreachable, timed out, connection dropped) rather than a query- or
36+
* validation-level error. Broad by design (matches the classifier used for
37+
* logging); for the retry decision use {@link isRetryableInfrastructureError}.
38+
*/
39+
export function isInfrastructureError(error: unknown): boolean {
40+
if (
41+
error instanceof Prisma.PrismaClientInitializationError ||
42+
error instanceof Prisma.PrismaClientRustPanicError ||
43+
error instanceof Prisma.PrismaClientUnknownRequestError
44+
) {
45+
return true;
46+
}
47+
48+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
49+
if (INFRASTRUCTURE_PRISMA_CODES.has(error.code)) {
50+
return true;
51+
}
52+
return error.code === "P2010" && looksLikeConnectivityError(error);
53+
}
54+
55+
return looksLikeConnectivityError(error);
56+
}
57+
58+
/**
59+
* True when `error` is a *transient* infrastructure failure worth retrying — a
60+
* genuine connectivity blip, not a permanent one. Narrower than
61+
* {@link isInfrastructureError}: an initialization or unknown-request error
62+
* counts only when it carries a connectivity signal (so a bad-URL / auth /
63+
* database-selection failure is NOT retried), and a Rust-engine panic is never
64+
* retried. This is the default retry gate for `withInfraRetry`.
65+
*/
66+
export function isRetryableInfrastructureError(error: unknown): boolean {
67+
// Never retry pool exhaustion (P2024): another attempt only competes for the
68+
// same exhausted pool. Checked before the connectivity fallbacks because its
69+
// message otherwise matches CONNECTIVITY_MESSAGE.
70+
const message = (error as { message?: unknown })?.message;
71+
if (
72+
(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2024") ||
73+
(typeof message === "string" && POOL_EXHAUSTION_MESSAGE.test(message))
74+
) {
75+
return false;
76+
}
77+
78+
if (error instanceof Prisma.PrismaClientRustPanicError) {
79+
return false;
80+
}
81+
82+
if (error instanceof Prisma.PrismaClientInitializationError) {
83+
return (
84+
(typeof error.errorCode === "string" && INFRASTRUCTURE_PRISMA_CODES.has(error.errorCode)) ||
85+
looksLikeConnectivityError(error)
86+
);
87+
}
88+
89+
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
90+
return looksLikeConnectivityError(error);
91+
}
92+
93+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
94+
if (INFRASTRUCTURE_PRISMA_CODES.has(error.code)) {
95+
return true;
96+
}
97+
return error.code === "P2010" && looksLikeConnectivityError(error);
98+
}
99+
100+
return looksLikeConnectivityError(error);
101+
}

0 commit comments

Comments
 (0)