|
| 1 | +/** |
| 2 | + * Retry helper for the listing read path. |
| 3 | + * |
| 4 | + * Scoped deliberately to database reads: the predicate here is about the |
| 5 | + * connection and the pool, not about an upstream HTTP provider. Anything the |
| 6 | + * database rejected on its own terms - a bad filter, a missing row, a type |
| 7 | + * mismatch - is not retried, because the same query will fail identically next |
| 8 | + * time and retrying only adds latency to a request that was always going to fail. |
| 9 | + */ |
| 10 | +export interface ListingRetryOptions { |
| 11 | + /** Total attempts including the first. Default 2. */ |
| 12 | + attempts?: number; |
| 13 | + /** Delay before the first retry in ms; doubles each retry. Default 50. */ |
| 14 | + baseDelayMs?: number; |
| 15 | + /** Ceiling on any single delay in ms. Default 400. */ |
| 16 | + maxDelayMs?: number; |
| 17 | + /** Injectable for tests. */ |
| 18 | + sleep?: (ms: number) => Promise<void>; |
| 19 | + /** Injectable for tests; returns a value in [0, 1). */ |
| 20 | + random?: () => number; |
| 21 | +} |
| 22 | + |
| 23 | +const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); |
| 24 | + |
| 25 | +/** |
| 26 | + * Prisma error codes that mean "try again", as opposed to "this query is |
| 27 | + * wrong". `P1001` cannot reach the server, `P1002` the server timed out, |
| 28 | + * `P1008` operations timed out, `P1017` the pool was closed. |
| 29 | + */ |
| 30 | +const TRANSIENT_PRISMA_CODES = new Set(['P1001', 'P1002', 'P1008', 'P1017']); |
| 31 | + |
| 32 | +const TRANSIENT_NETWORK = |
| 33 | + /ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|Client has encountered a connection error|connection pool timeout|Timeout expired/i; |
| 34 | + |
| 35 | +/** |
| 36 | + * Whether a failed listing read looks transient — a dropped connection, a |
| 37 | + * timeout, or the pool running dry under load — and so is worth one more try. |
| 38 | + */ |
| 39 | +export function isTransientListingReadError(error: unknown): boolean { |
| 40 | + if (!error) return false; |
| 41 | + |
| 42 | + const code = (error as { code?: string }).code; |
| 43 | + if (typeof code === 'string' && TRANSIENT_PRISMA_CODES.has(code)) return true; |
| 44 | + |
| 45 | + const name = (error as { name?: string }).name ?? ''; |
| 46 | + if (name === 'PrismaClientInitializationError' || name === 'PrismaClientKnownRequestError') { |
| 47 | + // A known request error carries an explicit code; if it is not one of the |
| 48 | + // transient ones the query itself is at fault, so only the initialisation |
| 49 | + // variant (connection unavailable) is retryable here. |
| 50 | + if (name === 'PrismaClientKnownRequestError') return false; |
| 51 | + } |
| 52 | + |
| 53 | + const message = (error as { message?: string }).message ?? ''; |
| 54 | + return TRANSIENT_NETWORK.test(message); |
| 55 | +} |
| 56 | + |
| 57 | +/** Delay before retry number `retry` (1-based): exponential, capped, with jitter. */ |
| 58 | +export function listingBackoffDelayMs( |
| 59 | + retry: number, |
| 60 | + baseDelayMs: number, |
| 61 | + maxDelayMs: number, |
| 62 | + random: () => number, |
| 63 | +): number { |
| 64 | + const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** (retry - 1)); |
| 65 | + return Math.round(exponential * (0.5 + random() * 0.5)); |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * Run a listing read, retrying transient failures with exponential backoff and |
| 70 | + * jitter. The last error is rethrown once the attempts run out. |
| 71 | + * |
| 72 | + * Only two attempts by default and a much shorter ceiling than a generic |
| 73 | + * retry helper: this sits in front of an interactive request, so the point is |
| 74 | + * to ride out a blip, not to survive an outage. A client that waits longer than |
| 75 | + * a second would rather get an error and retry than hold a connection open. |
| 76 | + */ |
| 77 | +export async function retryListingQuery<T>( |
| 78 | + fn: () => Promise<T>, |
| 79 | + options: ListingRetryOptions = {}, |
| 80 | +): Promise<T> { |
| 81 | + const { |
| 82 | + attempts = 2, |
| 83 | + baseDelayMs = 50, |
| 84 | + maxDelayMs = 400, |
| 85 | + sleep = defaultSleep, |
| 86 | + random = Math.random, |
| 87 | + } = options; |
| 88 | + |
| 89 | + let lastError: unknown; |
| 90 | + for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) { |
| 91 | + try { |
| 92 | + return await fn(); |
| 93 | + } catch (error) { |
| 94 | + lastError = error; |
| 95 | + if (attempt >= attempts || !isTransientListingReadError(error)) throw error; |
| 96 | + await sleep(listingBackoffDelayMs(attempt, baseDelayMs, maxDelayMs, random)); |
| 97 | + } |
| 98 | + } |
| 99 | + throw lastError; |
| 100 | +} |
0 commit comments