fix(faza-e): refresh dedup, fetch timeout, error surfacing

4 fixes for the symptoms Marius hit on Faza E pilot — search returning
"Eroare la căutare" after sessions got stale, even after relogin:

1. Refresh deduplication
   Authentik rotates refresh_tokens — exchange-once. Parallel map +
   search + parcela.get all hit jwt callback concurrently, each fires
   its own refresh, the first wins, the rest get invalid_grant and
   poison the JWT with token.error=RefreshAccessTokenError → user
   appears logged out for no good reason. Cache the inflight refresh
   promise in-memory keyed by refresh_token so concurrent callers
   share one Authentik exchange.

2. Fetch timeout in gis-api-client
   AbortSignal.timeout(30s) on every api.gis.ac call. Without it, a
   slow upstream (ANCPI scrape, orchestrator hiccup) hangs the route
   for the full Next.js default → Marius saw 10s gaps with no
   feedback. Throws GisApiError(504, upstream_timeout) instead.

3. Better error surfacing
   /api/gis/* routes return { error, hint: <first 200 chars> } on
   non-GisApiError throws instead of a bare "internal_error". Easier
   to triage from browser DevTools without paging through container
   logs.

4. Remove diagnostic [gis-search] logs
   Diagnostic served its purpose (identified the stale-token cause
   pre-refresh-fix). Now noise; keep only [auth] refresh success/fail
   + per-route internal_error.

Also adds AbortSignal.timeout(8s) on the Authentik refresh fetch
itself to keep the jwt callback bounded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-18 22:58:16 +03:00
parent 47ca366984
commit 6054d083b5
5 changed files with 137 additions and 80 deletions
+7 -3
View File
@@ -26,8 +26,7 @@ export async function POST(request: Request) {
} }
try { try {
const data = await gisApi.parcel.tech(body); return NextResponse.json(await gisApi.parcel.tech(body));
return NextResponse.json(data);
} catch (err) { } catch (err) {
if (err instanceof GisApiError) { if (err instanceof GisApiError) {
return NextResponse.json( return NextResponse.json(
@@ -35,6 +34,11 @@ export async function POST(request: Request) {
{ status: err.status }, { status: err.status },
); );
} }
return NextResponse.json({ error: "internal_error" }, { status: 500 }); const msg = err instanceof Error ? err.message : String(err);
console.error("[gis-parcel-tech] internal error:", msg);
return NextResponse.json(
{ error: "internal_error", hint: msg.slice(0, 200) },
{ status: 500 },
);
} }
} }
+7 -3
View File
@@ -20,8 +20,7 @@ export async function GET(
} }
try { try {
const data = await gisApi.parcela.get(id); return NextResponse.json(await gisApi.parcela.get(id));
return NextResponse.json(data);
} catch (err) { } catch (err) {
if (err instanceof GisApiError) { if (err instanceof GisApiError) {
return NextResponse.json( return NextResponse.json(
@@ -29,6 +28,11 @@ export async function GET(
{ status: err.status }, { status: err.status },
); );
} }
return NextResponse.json({ error: "internal_error" }, { status: 500 }); const msg = err instanceof Error ? err.message : String(err);
console.error("[gis-parcela] internal error:", msg);
return NextResponse.json(
{ error: "internal_error", hint: msg.slice(0, 200) },
{ status: 500 },
);
} }
} }
+7 -17
View File
@@ -7,17 +7,6 @@ export const dynamic = "force-dynamic";
export async function GET(request: Request) { export async function GET(request: Request) {
const session = await getAuthSession(); const session = await getAuthSession();
const hasSession = !!session;
const sessionUserEmail = session?.user?.email ?? null;
const hasAccessToken = !!(session as { accessToken?: string } | null)
?.accessToken;
console.log(
"[gis-search] hasSession=%s email=%s hasAccessToken=%s",
hasSession,
sessionUserEmail,
hasAccessToken,
);
if (!session) { if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
@@ -32,18 +21,19 @@ export async function GET(request: Request) {
} }
try { try {
const data = await gisApi.search(q, limit); return NextResponse.json(await gisApi.search(q, limit));
console.log("[gis-search] OK q=%s uats=%d features=%d", q, data.uats?.length ?? 0, data.features?.length ?? 0);
return NextResponse.json(data);
} catch (err) { } catch (err) {
if (err instanceof GisApiError) { if (err instanceof GisApiError) {
console.error("[gis-search] gis-api error code=%s status=%d body=%j", err.code, err.status, err.body);
return NextResponse.json( return NextResponse.json(
{ error: err.code, status: err.status }, { error: err.code, status: err.status },
{ status: err.status }, { status: err.status },
); );
} }
console.error("[gis-search] internal error:", err); const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: "internal_error" }, { status: 500 }); console.error("[gis-search] internal error:", msg);
return NextResponse.json(
{ error: "internal_error", hint: msg.slice(0, 200) },
{ status: 500 },
);
} }
} }
+35 -4
View File
@@ -12,13 +12,35 @@ import { useGisAcFlag } from "@/core/feature-flags/use-gis-ac";
* *
* Marks the JWT with token.error="RefreshAccessTokenError" on failure; * Marks the JWT with token.error="RefreshAccessTokenError" on failure;
* the client can react by forcing a re-login. * the client can react by forcing a re-login.
*
* **Concurrency:** Authentik rotates refresh_tokens — a refresh_token can
* only be exchanged once. With parallel requests (typical for a map app —
* map tiles + parcela.get + search all firing at once), each request
* triggers its own jwt callback, each calls refresh, only the first wins
* and the rest get `invalid_grant` → user is logged out for no good
* reason. We dedupe in-memory by refresh_token key so concurrent callers
* share a single Authentik request.
*/ */
const inflightRefreshes = new Map<string, Promise<JWT>>();
async function refreshAuthentikToken(token: JWT): Promise<JWT> { async function refreshAuthentikToken(token: JWT): Promise<JWT> {
const refreshToken = token.refreshToken ? String(token.refreshToken) : "";
if (!refreshToken) {
return { ...token, error: "RefreshAccessTokenError" };
}
// Dedupe concurrent refreshes for the same refresh_token.
const existing = inflightRefreshes.get(refreshToken);
if (existing) {
return existing.then((fresh) => ({ ...token, ...fresh }));
}
const promise = (async (): Promise<JWT> => {
try { try {
const issuer = process.env.AUTHENTIK_ISSUER; const issuer = process.env.AUTHENTIK_ISSUER;
const clientId = process.env.AUTHENTIK_CLIENT_ID; const clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
if (!issuer || !clientId || !clientSecret || !token.refreshToken) { if (!issuer || !clientId || !clientSecret) {
throw new Error("refresh_prerequisites_missing"); throw new Error("refresh_prerequisites_missing");
} }
const url = `${issuer.replace(/\/$/, "")}/token/`; const url = `${issuer.replace(/\/$/, "")}/token/`;
@@ -27,11 +49,12 @@ async function refreshAuthentikToken(token: JWT): Promise<JWT> {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: String(token.refreshToken), refresh_token: refreshToken,
client_id: clientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
}), }),
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(8_000),
}); });
const body = (await res.json()) as { const body = (await res.json()) as {
access_token?: string; access_token?: string;
@@ -43,17 +66,25 @@ async function refreshAuthentikToken(token: JWT): Promise<JWT> {
console.warn("[auth] refresh failed:", res.status, body.error); console.warn("[auth] refresh failed:", res.status, body.error);
return { ...token, error: "RefreshAccessTokenError" }; return { ...token, error: "RefreshAccessTokenError" };
} }
console.log("[auth] refresh OK expires_in=%d", body.expires_in ?? 300);
return { return {
...token, ...token,
accessToken: body.access_token, accessToken: body.access_token,
accessTokenExpires: Date.now() + (body.expires_in ?? 300) * 1000, accessTokenExpires: Date.now() + (body.expires_in ?? 300) * 1000,
refreshToken: body.refresh_token ?? token.refreshToken, refreshToken: body.refresh_token ?? refreshToken,
error: undefined, error: undefined,
}; };
} catch (err) { } catch (err) {
console.warn("[auth] refresh error:", err); const msg = err instanceof Error ? err.message : String(err);
console.warn("[auth] refresh error:", msg);
return { ...token, error: "RefreshAccessTokenError" }; return { ...token, error: "RefreshAccessTokenError" };
} finally {
inflightRefreshes.delete(refreshToken);
} }
})();
inflightRefreshes.set(refreshToken, promise);
return promise;
} }
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
+30 -2
View File
@@ -143,9 +143,15 @@ function parseRateLimit(res: Response): RateLimit | undefined {
}; };
} }
// Default per-request timeout. Light queries (search, parcela.get) usually
// finish in <1s; proxy calls (parcel/tech) hit ANCPI live and can take 5-15s.
const DEFAULT_TIMEOUT_MS = 30_000;
async function request<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> { async function request<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
const token = opts.accessToken || (await bearerFromSession()); const token = opts.accessToken || (await bearerFromSession());
const res = await fetch(buildUrl(path, opts.query), { let res: Response;
try {
res = await fetch(buildUrl(path, opts.query), {
method: opts.method || "GET", method: opts.method || "GET",
body: opts.body ?? null, body: opts.body ?? null,
headers: { headers: {
@@ -154,7 +160,17 @@ async function request<T = unknown>(path: string, opts: RequestOpts = {}): Promi
...opts.headers, ...opts.headers,
}, },
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
}); });
} catch (err) {
const isAbort = (err as { name?: string })?.name === "TimeoutError" ||
(err as { name?: string })?.name === "AbortError";
throw new GisApiError(
504,
isAbort ? "upstream_timeout" : "upstream_unreachable",
{ hint: (err as Error)?.message },
);
}
const rateLimit = parseRateLimit(res); const rateLimit = parseRateLimit(res);
@@ -178,7 +194,9 @@ async function request<T = unknown>(path: string, opts: RequestOpts = {}): Promi
async function rawResponse(path: string, opts: RequestOpts = {}): Promise<Response> { async function rawResponse(path: string, opts: RequestOpts = {}): Promise<Response> {
const token = opts.accessToken || (await bearerFromSession()); const token = opts.accessToken || (await bearerFromSession());
const res = await fetch(buildUrl(path, opts.query), { let res: Response;
try {
res = await fetch(buildUrl(path, opts.query), {
method: opts.method || "GET", method: opts.method || "GET",
body: opts.body ?? null, body: opts.body ?? null,
headers: { headers: {
@@ -186,7 +204,17 @@ async function rawResponse(path: string, opts: RequestOpts = {}): Promise<Respon
...opts.headers, ...opts.headers,
}, },
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
}); });
} catch (err) {
const isAbort = (err as { name?: string })?.name === "TimeoutError" ||
(err as { name?: string })?.name === "AbortError";
throw new GisApiError(
504,
isAbort ? "upstream_timeout" : "upstream_unreachable",
{ hint: (err as Error)?.message },
);
}
if (!res.ok) { if (!res.ok) {
let body: unknown = null; let body: unknown = null;
try { try {