| import { createClient, type SupabaseClient } from "@supabase/supabase-js"; |
|
|
| let cachedClient: SupabaseClient | null = null; |
|
|
| function parsePositiveInt(value: string | undefined, fallback: number) { |
| const parsed = Number.parseInt(value ?? "", 10); |
| return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; |
| } |
|
|
| function parseRetryAttempts(value: string | undefined, fallback: number) { |
| const parsed = Number.parseInt(value ?? "", 10); |
| if (!Number.isFinite(parsed)) { |
| return fallback; |
| } |
| return Math.max(0, parsed); |
| } |
|
|
| function shouldRetryStatus(status: number) { |
| return status === 408 || status === 425 || status === 429 || status >= 500; |
| } |
|
|
| function isRetryableNetworkError(error: unknown) { |
| if (!error || typeof error !== "object") { |
| return false; |
| } |
| const name = "name" in error ? String((error as { name?: unknown }).name ?? "") : ""; |
| const message = |
| "message" in error ? String((error as { message?: unknown }).message ?? "") : ""; |
| if (name === "AbortError") { |
| return true; |
| } |
| return /fetch failed|network|socket|timed out|timeout|econnreset|enotfound|eai_again/i.test( |
| message |
| ); |
| } |
|
|
| function isIdempotentMethod(method: string) { |
| return method === "GET" || method === "HEAD" || method === "OPTIONS"; |
| } |
|
|
| function sleep(ms: number) { |
| return new Promise((resolve) => setTimeout(resolve, ms)); |
| } |
|
|
| async function fetchWithRetry(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { |
| const method = (init?.method ?? "GET").toUpperCase(); |
| const retryAttempts = parseRetryAttempts(process.env.SUPABASE_HTTP_RETRY_ATTEMPTS, 2); |
| const timeoutMs = parsePositiveInt(process.env.SUPABASE_HTTP_TIMEOUT_MS, 15_000); |
| const backoffMs = parsePositiveInt(process.env.SUPABASE_HTTP_RETRY_BACKOFF_MS, 350); |
| const allowWriteRetries = process.env.SUPABASE_HTTP_RETRY_WRITES === "1"; |
| const canRetry = allowWriteRetries || isIdempotentMethod(method); |
| const totalAttempts = canRetry ? retryAttempts + 1 : 1; |
|
|
| let attempt = 0; |
| let lastError: unknown = null; |
|
|
| while (attempt < totalAttempts) { |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), timeoutMs); |
|
|
| try { |
| const response = await fetch(input, { |
| ...init, |
| signal: controller.signal |
| }); |
| clearTimeout(timeout); |
|
|
| const isFinalAttempt = attempt === totalAttempts - 1; |
| if (!shouldRetryStatus(response.status) || isFinalAttempt) { |
| return response; |
| } |
| } catch (error) { |
| clearTimeout(timeout); |
| lastError = error; |
| const isFinalAttempt = attempt === totalAttempts - 1; |
| if (!canRetry || !isRetryableNetworkError(error) || isFinalAttempt) { |
| throw error; |
| } |
| } |
|
|
| attempt += 1; |
| const waitMs = backoffMs * Math.max(1, attempt); |
| await sleep(waitMs); |
| } |
|
|
| if (lastError) { |
| throw lastError; |
| } |
|
|
| throw new Error("Supabase fetch retry exhausted without response."); |
| } |
|
|
| export function getSupabaseAdmin() { |
| if (cachedClient) { |
| return cachedClient; |
| } |
|
|
| const url = process.env.NEXT_PUBLIC_SUPABASE_URL; |
| const key = process.env.SUPABASE_SERVICE_ROLE_KEY; |
| if (!url || !key) { |
| throw new Error( |
| "Missing Supabase configuration. Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY." |
| ); |
| } |
|
|
| cachedClient = createClient(url, key, { |
| auth: { |
| persistSession: false, |
| autoRefreshToken: false |
| }, |
| global: { |
| fetch: fetchWithRetry |
| } |
| }); |
|
|
| return cachedClient; |
| } |
|
|