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:
@@ -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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 +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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,48 +12,79 @@ 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> {
|
||||||
try {
|
const refreshToken = token.refreshToken ? String(token.refreshToken) : "";
|
||||||
const issuer = process.env.AUTHENTIK_ISSUER;
|
if (!refreshToken) {
|
||||||
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);
|
|
||||||
return { ...token, error: "RefreshAccessTokenError" };
|
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 {
|
||||||
|
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 = {
|
export const authOptions: NextAuthOptions = {
|
||||||
|
|||||||
+47
-19
@@ -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<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;
|
||||||
method: opts.method || "GET",
|
try {
|
||||||
body: opts.body ?? null,
|
res = await fetch(buildUrl(path, opts.query), {
|
||||||
headers: {
|
method: opts.method || "GET",
|
||||||
Authorization: `Bearer ${token}`,
|
body: opts.body ?? null,
|
||||||
Accept: "application/json",
|
headers: {
|
||||||
...opts.headers,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
Accept: "application/json",
|
||||||
cache: "no-store",
|
...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);
|
const rateLimit = parseRateLimit(res);
|
||||||
|
|
||||||
@@ -178,15 +194,27 @@ 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;
|
||||||
method: opts.method || "GET",
|
try {
|
||||||
body: opts.body ?? null,
|
res = await fetch(buildUrl(path, opts.query), {
|
||||||
headers: {
|
method: opts.method || "GET",
|
||||||
Authorization: `Bearer ${token}`,
|
body: opts.body ?? null,
|
||||||
...opts.headers,
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${token}`,
|
||||||
cache: "no-store",
|
...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) {
|
if (!res.ok) {
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user