Feat: Auto-resolve backend API base URL so Swarm redeploys no longer break proxied requests (fetch failed)
This commit is contained in:
1 parent
856acdb6da
commit
d492adb1ff
2 files changed
+86
-6
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getApiBase, resetApiBase } from '@/lib/api-base';
|
||||||
|
|
||||||
export async function GET(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
|
export async function GET(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
|
||||||
return handleProxy(req, props);
|
return handleProxy(req, props);
|
||||||
@@ -27,9 +28,10 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
|||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
// INTERNAL_API_URL is read at RUNTIME (not baked at build time like NEXT_PUBLIC_*)
|
// INTERNAL_API_URL is read at RUNTIME (not baked at build time like NEXT_PUBLIC_*).
|
||||||
// This allows Docker Swarm / Portainer to set the backend URL dynamically
|
// getApiBase() probes configured URLs and transparently falls back to the stable
|
||||||
const localBase = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001';
|
// Swarm service alias if the configured one (e.g. task hostname ...-0) is unreachable.
|
||||||
|
const localBase = await getApiBase();
|
||||||
const destinationUrl = `${localBase}/api/${route}${url.search}`;
|
const destinationUrl = `${localBase}/api/${route}${url.search}`;
|
||||||
|
|
||||||
// Prepare headers
|
// Prepare headers
|
||||||
@@ -67,7 +69,7 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
|||||||
headers,
|
headers,
|
||||||
body: body,
|
body: body,
|
||||||
cache: 'no-store', // Prevent Next.js from aggressively caching the backend response
|
cache: 'no-store', // Prevent Next.js from aggressively caching the backend response
|
||||||
// @ts-ignore
|
// @ts-expect-error - `duplex` is required for streaming bodies in older fetch typings
|
||||||
duplex: hasBody ? 'half' : undefined,
|
duplex: hasBody ? 'half' : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,8 +95,11 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nextResponse;
|
return nextResponse;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
// Backend may have been redeployed (task hostname changed) — re-resolve next request.
|
||||||
|
resetApiBase();
|
||||||
console.error('[Proxy Error]', error);
|
console.error('[Proxy Error]', error);
|
||||||
return NextResponse.json({ ok: false, message: error.message }, { status: 500 });
|
const message = error instanceof Error ? error.message : 'Unknown proxy error';
|
||||||
|
return NextResponse.json({ ok: false, message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// src/lib/api-base.ts
|
||||||
|
// Server-only helper: resolve the backend API base URL at runtime.
|
||||||
|
//
|
||||||
|
// Why:
|
||||||
|
// The frontend proxies /api/* to the backend via NEXT_PUBLIC_API_URL /
|
||||||
|
// INTERNAL_API_URL. In Docker Swarm deployments these may point to a
|
||||||
|
// task-instance hostname like `backone-inspect-backend-bun-0:3001`. When the
|
||||||
|
// Swarm service is redeployed the task index can change (`-0` -> `-1`), orphaning
|
||||||
|
// the configured URL and breaking every proxied request ("fetch failed").
|
||||||
|
//
|
||||||
|
// Solution:
|
||||||
|
// Probe candidates in order and cache the first healthy one. Also auto-derive
|
||||||
|
// the stable service alias by stripping a trailing `-<digits>` suffix, so a
|
||||||
|
// stale `-0` URL transparently recovers to the plain service DNS name.
|
||||||
|
|
||||||
|
let cachedBase: string | null = null;
|
||||||
|
|
||||||
|
function pushUnique(list: string[], value?: string | null) {
|
||||||
|
if (!value) return;
|
||||||
|
const clean = value.replace(/\/+$/, '');
|
||||||
|
if (clean && !list.includes(clean)) list.push(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCandidates(): string[] {
|
||||||
|
const list: string[] = [];
|
||||||
|
pushUnique(list, process.env.INTERNAL_API_URL);
|
||||||
|
pushUnique(list, process.env.NEXT_PUBLIC_API_URL);
|
||||||
|
|
||||||
|
// Derive stable Swarm service alias (strip task index: ...-0 -> ...)
|
||||||
|
for (const base of [...list]) {
|
||||||
|
try {
|
||||||
|
const u = new URL(base);
|
||||||
|
const strippedHost = u.host.replace(/-\d+$/, '');
|
||||||
|
if (strippedHost && strippedHost !== u.host) {
|
||||||
|
pushUnique(list, `${u.protocol}//${strippedHost}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed URLs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUnique(list, 'http://127.0.0.1:3001');
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isHealthy(base: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 2500);
|
||||||
|
const res = await fetch(`${base}/api/health`, { signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApiBase(): Promise<string> {
|
||||||
|
if (cachedBase) return cachedBase;
|
||||||
|
|
||||||
|
for (const base of buildCandidates()) {
|
||||||
|
if (await isHealthy(base)) {
|
||||||
|
cachedBase = base;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use the first configured candidate even if the probe failed.
|
||||||
|
cachedBase = buildCandidates()[0] || 'http://127.0.0.1:3001';
|
||||||
|
return cachedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetApiBase() {
|
||||||
|
cachedBase = null;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user