Per user report 2026-05-14: 'Failed to execute text on Response: body stream
already read' при analyze с non-JSON error response.
apiFetchWithStatus делал .json() (consumes stream), потом .text() в catch
(FAILS — stream already consumed). Fetch API: body — ReadableStream,
consumable один раз.
Fix: read text() once → JSON.parse on string. На fail (HTML / non-JSON)
оборачиваем в {detail: rawText}. Никаких double-reads.
Site-finder cad search больше не крашится на error pages.
Co-authored-by: lekss361 <claudestars@proton.me>
69 lines
2.2 KiB
TypeScript
69 lines
2.2 KiB
TypeScript
// Empty default = same-origin relative URLs.
|
||
// In prod Caddy proxies /api/* to backend; in dev Next.js rewrites do the same.
|
||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||
|
||
export async function apiFetch<T>(
|
||
path: string,
|
||
init?: RequestInit,
|
||
): Promise<T> {
|
||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||
...init,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(init?.headers ?? {}),
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
||
}
|
||
return response.json() as Promise<T>;
|
||
}
|
||
|
||
/**
|
||
* Status-aware variant of apiFetch — возвращает status + body для случаев
|
||
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
|
||
*
|
||
* Throws на 4xx/5xx (используя HTTPError ниже); 2xx коды все ОК.
|
||
*/
|
||
export class HTTPError extends Error {
|
||
status: number;
|
||
body: unknown;
|
||
constructor(status: number, body: unknown, msg?: string) {
|
||
super(msg ?? `API error ${status}`);
|
||
this.status = status;
|
||
this.body = body;
|
||
}
|
||
}
|
||
|
||
export async function apiFetchWithStatus<T>(
|
||
path: string,
|
||
init?: RequestInit,
|
||
): Promise<{ status: number; body: T }> {
|
||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||
...init,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(init?.headers ?? {}),
|
||
},
|
||
});
|
||
let body: unknown = null;
|
||
// 204 No Content имеет пустое тело; для всех остальных читаем как text один раз
|
||
// (fetch body stream consumable только один раз — нельзя сначала .json() потом .text()).
|
||
if (response.status !== 204) {
|
||
const rawText = await response.text();
|
||
if (rawText) {
|
||
try {
|
||
body = JSON.parse(rawText);
|
||
} catch {
|
||
// Не JSON (HTML error page от Caddy / 502 / etc) — оборачиваем в detail
|
||
body = { detail: rawText };
|
||
}
|
||
}
|
||
}
|
||
if (!response.ok) {
|
||
const detail =
|
||
(body as { detail?: string })?.detail ?? `API error ${response.status}`;
|
||
throw new HTTPError(response.status, body, detail);
|
||
}
|
||
return { status: response.status, body: body as T };
|
||
}
|