forked from StableRoute-Org/Stableroute-frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapiClient.ts
More file actions
117 lines (107 loc) · 3.81 KB
/
Copy pathapiClient.ts
File metadata and controls
117 lines (107 loc) · 3.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import { getApiBase } from "./config";
export type ApiError = {
error: string;
message: string;
requestId?: string;
};
export type ApiFetchOptions = {
/** Opt-in retry with exponential backoff for idempotent GET/HEAD requests. */
retry?: {
maxAttempts?: number;
baseDelayMs?: number;
};
/** Request timeout in milliseconds. Default 15000. */
timeoutMs?: number;
};
type AuthErrorHandler = (status: 401 | 403) => void;
let _authErrorHandler: AuthErrorHandler | null = null;
const DEFAULT_TIMEOUT_MS = 15_000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Called once by <ApiAuthGuard> when it mounts inside <ToastProvider>. */
export function registerAuthErrorHandler(handler: AuthErrorHandler): () => void {
_authErrorHandler = handler;
return () => {
if (_authErrorHandler === handler) _authErrorHandler = null;
};
}
async function parseResponse<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as T;
const text = await res.text();
let body: T | ApiError | undefined;
if (text) {
try {
body = JSON.parse(text) as T | ApiError;
} catch {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
throw new Error("Invalid JSON response");
}
}
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
_authErrorHandler?.(res.status as 401 | 403);
}
const msg = (body as ApiError | undefined)?.message ?? `HTTP ${res.status}`;
const err = Object.assign(new Error(msg), { status: res.status }, body ?? {});
throw err;
}
return body as T;
}
export async function apiFetch<T>(
path: string,
init: RequestInit = {},
options?: ApiFetchOptions,
): Promise<T> {
const method = (init.method ?? "GET").toUpperCase();
const canRetry = method === "GET" || method === "HEAD";
const maxAttempts =
canRetry && options?.retry ? Math.max(1, options.retry.maxAttempts ?? 3) : 1;
const baseDelayMs = options?.retry?.baseDelayMs ?? 100;
let lastError: unknown;
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${getApiBase()}${path}`, {
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
signal: controller.signal,
...init,
});
if (!res.ok && res.status >= 500 && attempt < maxAttempts) {
await sleep(baseDelayMs * 2 ** (attempt - 1));
continue;
}
return await parseResponse<T>(res);
} catch (err) {
if (
err instanceof Error &&
("status" in err ||
err.message === "Invalid JSON response" ||
err.message.startsWith("HTTP "))
) {
throw err;
}
lastError = err;
const message =
err instanceof DOMException && err.name === "AbortError"
? "Request timed out"
: "Network request failed";
if (attempt < maxAttempts) {
await sleep(baseDelayMs * 2 ** (attempt - 1));
continue;
}
throw new Error(message);
} finally {
clearTimeout(timer);
}
}
throw lastError ?? new Error("request failed");
}
export const apiGet = <T>(path: string, options?: ApiFetchOptions) =>
apiFetch<T>(path, {}, options);
export const apiPost = <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: "POST", body: JSON.stringify(body) });
export const apiPatch = <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: "PATCH", body: JSON.stringify(body) });
export const apiDelete = (path: string) =>
apiFetch<void>(path, { method: "DELETE" });