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) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<uuid>/<file>.jpg
|
||||
* /img/bb-forum-avatars/<file>.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<Response> {
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user