From 445a11a1eebe5af5479d590358ff97f75eff5ff5 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Fri, 29 May 2026 19:34:24 +0000 Subject: [PATCH] fix(img-proxy): retry transient 5xx, no-store on failure, dynamic route Bug: a single transient Supabase storage 5xx was being permanently cached because the route was force-static + force-cache, and the wrapping page has s-maxage=31536000. Result: real image (still in bucket) showed as broken on production article pages. Fix: - dynamic = force-dynamic, revalidate = 0 (no build-time pinning) - fetch with no-store + 3-try retry with backoff for 5xx - failures emit cache-control: no-store so they don't pin Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/img/[...path]/route.ts | 48 +++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/app/img/[...path]/route.ts b/src/app/img/[...path]/route.ts index 0e88a9c..4b348e0 100644 --- a/src/app/img/[...path]/route.ts +++ b/src/app/img/[...path]/route.ts @@ -1,7 +1,8 @@ import { NextRequest } from "next/server"; export const runtime = "nodejs"; -export const dynamic = "force-static"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; /** * Image proxy: serves files from Supabase Storage under a clean broadcastbeat.com @@ -13,24 +14,59 @@ export const dynamic = "force-static"; * /img/campaign-creatives//.jpg * /img/bb-forum-avatars/.png * - * Path segments are forwarded directly to the matching public bucket. + * Resilience rules (prior bug): + * - Supabase storage occasionally returns transient 5xx. We retry up to 2x + * with backoff before surfacing failure. + * - Failures MUST emit no-store cache headers, otherwise Next + Cloudflare + * pin the failure indefinitely (page itself had s-maxage of one year). + * - The route must be dynamic — force-static used to pre-bake a single + * fetch result at build time, which is exactly the bug we hit. */ const SUPABASE_BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase.onsethost.com") .replace(/\/+$/, "") + "/storage/v1/object/public"; +async function fetchWithRetry(url: string, tries = 3): Promise { + let lastErr: unknown = null; + for (let i = 0; i < tries; i++) { + try { + const r = await fetch(url, { cache: "no-store" }); + if (r.ok) return r; + if (r.status === 404) return r; + // 5xx — back off and retry + lastErr = new Error(`upstream ${r.status}`); + } catch (e) { + lastErr = e; + } + if (i < tries - 1) await new Promise((res) => setTimeout(res, 150 * (i + 1))); + } + throw lastErr instanceof Error ? lastErr : new Error("upstream failed"); +} + +function failResponse(status: number, body: string) { + return new Response(body, { + status, + headers: { + "content-type": "text/plain; charset=utf-8", + // Critical: never cache failures. + "cache-control": "no-store, max-age=0", + "x-bb-img-source": "proxy-error", + }, + }); +} + export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { const { path } = await params; - if (!path || path.length < 2) return new Response("bad path", { status: 400 }); + if (!path || path.length < 2) return failResponse(400, "bad path"); const target = `${SUPABASE_BASE}/${path.map(encodeURIComponent).join("/")}`; let upstream: Response; try { - upstream = await fetch(target, { cache: "force-cache" }); + upstream = await fetchWithRetry(target, 3); } catch (e: any) { - return new Response("upstream error: " + (e?.message || String(e)), { status: 502 }); + return failResponse(502, "upstream error after retries: " + (e?.message || String(e))); } if (!upstream.ok) { - return new Response("not found", { status: upstream.status === 404 ? 404 : 502 }); + return failResponse(upstream.status === 404 ? 404 : 502, "not found"); } return new Response(upstream.body, {