From 6054d083b53e6002d212cf666ea1737d6889266b Mon Sep 17 00:00:00 2001 From: Claude VM Date: Mon, 18 May 2026 22:58:16 +0300 Subject: [PATCH] fix(faza-e): refresh dedup, fetch timeout, error surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: } 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) --- src/app/api/gis/parcel/tech/route.ts | 10 ++- src/app/api/gis/parcela/[id]/route.ts | 10 ++- src/app/api/gis/search/route.ts | 24 ++---- src/core/auth/auth-options.ts | 107 +++++++++++++++++--------- src/lib/gis-api-client.ts | 66 +++++++++++----- 5 files changed, 137 insertions(+), 80 deletions(-) diff --git a/src/app/api/gis/parcel/tech/route.ts b/src/app/api/gis/parcel/tech/route.ts index 1194c6f..4139c95 100644 --- a/src/app/api/gis/parcel/tech/route.ts +++ b/src/app/api/gis/parcel/tech/route.ts @@ -26,8 +26,7 @@ export async function POST(request: Request) { } try { - const data = await gisApi.parcel.tech(body); - return NextResponse.json(data); + return NextResponse.json(await gisApi.parcel.tech(body)); } catch (err) { if (err instanceof GisApiError) { return NextResponse.json( @@ -35,6 +34,11 @@ export async function POST(request: Request) { { 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 }, + ); } } diff --git a/src/app/api/gis/parcela/[id]/route.ts b/src/app/api/gis/parcela/[id]/route.ts index 3817e5a..055e0d5 100644 --- a/src/app/api/gis/parcela/[id]/route.ts +++ b/src/app/api/gis/parcela/[id]/route.ts @@ -20,8 +20,7 @@ export async function GET( } try { - const data = await gisApi.parcela.get(id); - return NextResponse.json(data); + return NextResponse.json(await gisApi.parcela.get(id)); } catch (err) { if (err instanceof GisApiError) { return NextResponse.json( @@ -29,6 +28,11 @@ export async function GET( { 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 }, + ); } } diff --git a/src/app/api/gis/search/route.ts b/src/app/api/gis/search/route.ts index c9b5b1f..2c9b2a7 100644 --- a/src/app/api/gis/search/route.ts +++ b/src/app/api/gis/search/route.ts @@ -7,17 +7,6 @@ export const dynamic = "force-dynamic"; export async function GET(request: Request) { 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) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -32,18 +21,19 @@ export async function GET(request: Request) { } try { - const data = 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); + return NextResponse.json(await gisApi.search(q, limit)); } catch (err) { 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( { error: err.code, status: err.status }, { status: err.status }, ); } - console.error("[gis-search] internal error:", err); - return NextResponse.json({ error: "internal_error" }, { status: 500 }); + const msg = err instanceof Error ? err.message : String(err); + console.error("[gis-search] internal error:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); } } diff --git a/src/core/auth/auth-options.ts b/src/core/auth/auth-options.ts index e047ba8..6d3760b 100644 --- a/src/core/auth/auth-options.ts +++ b/src/core/auth/auth-options.ts @@ -12,48 +12,79 @@ import { useGisAcFlag } from "@/core/feature-flags/use-gis-ac"; * * Marks the JWT with token.error="RefreshAccessTokenError" on failure; * 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>(); + async function refreshAuthentikToken(token: JWT): Promise { - try { - const issuer = process.env.AUTHENTIK_ISSUER; - const clientId = process.env.AUTHENTIK_CLIENT_ID; - const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; - if (!issuer || !clientId || !clientSecret || !token.refreshToken) { - throw new Error("refresh_prerequisites_missing"); - } - const url = `${issuer.replace(/\/$/, "")}/token/`; - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: String(token.refreshToken), - client_id: clientId, - client_secret: clientSecret, - }), - cache: "no-store", - }); - const body = (await res.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - error?: string; - }; - if (!res.ok || !body.access_token) { - console.warn("[auth] refresh failed:", res.status, body.error); - return { ...token, error: "RefreshAccessTokenError" }; - } - return { - ...token, - accessToken: body.access_token, - accessTokenExpires: Date.now() + (body.expires_in ?? 300) * 1000, - refreshToken: body.refresh_token ?? token.refreshToken, - error: undefined, - }; - } catch (err) { - console.warn("[auth] refresh error:", err); + 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 => { + try { + const issuer = process.env.AUTHENTIK_ISSUER; + const clientId = process.env.AUTHENTIK_CLIENT_ID; + const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; + if (!issuer || !clientId || !clientSecret) { + throw new Error("refresh_prerequisites_missing"); + } + const url = `${issuer.replace(/\/$/, "")}/token/`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }), + cache: "no-store", + signal: AbortSignal.timeout(8_000), + }); + const body = (await res.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + error?: string; + }; + if (!res.ok || !body.access_token) { + console.warn("[auth] refresh failed:", res.status, body.error); + return { ...token, error: "RefreshAccessTokenError" }; + } + console.log("[auth] refresh OK expires_in=%d", body.expires_in ?? 300); + return { + ...token, + accessToken: body.access_token, + accessTokenExpires: Date.now() + (body.expires_in ?? 300) * 1000, + refreshToken: body.refresh_token ?? refreshToken, + error: undefined, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[auth] refresh error:", msg); + return { ...token, error: "RefreshAccessTokenError" }; + } finally { + inflightRefreshes.delete(refreshToken); + } + })(); + + inflightRefreshes.set(refreshToken, promise); + return promise; } export const authOptions: NextAuthOptions = { diff --git a/src/lib/gis-api-client.ts b/src/lib/gis-api-client.ts index b526b08..36674b8 100644 --- a/src/lib/gis-api-client.ts +++ b/src/lib/gis-api-client.ts @@ -143,18 +143,34 @@ 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(path: string, opts: RequestOpts = {}): Promise { const token = opts.accessToken || (await bearerFromSession()); - const res = await fetch(buildUrl(path, opts.query), { - method: opts.method || "GET", - body: opts.body ?? null, - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - ...opts.headers, - }, - cache: "no-store", - }); + let res: Response; + try { + res = await fetch(buildUrl(path, opts.query), { + method: opts.method || "GET", + body: opts.body ?? null, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...opts.headers, + }, + 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); @@ -178,15 +194,27 @@ async function request(path: string, opts: RequestOpts = {}): Promi async function rawResponse(path: string, opts: RequestOpts = {}): Promise { const token = opts.accessToken || (await bearerFromSession()); - const res = await fetch(buildUrl(path, opts.query), { - method: opts.method || "GET", - body: opts.body ?? null, - headers: { - Authorization: `Bearer ${token}`, - ...opts.headers, - }, - cache: "no-store", - }); + let res: Response; + try { + res = await fetch(buildUrl(path, opts.query), { + method: opts.method || "GET", + body: opts.body ?? null, + headers: { + Authorization: `Bearer ${token}`, + ...opts.headers, + }, + 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) { let body: unknown = null; try {