forked from veridatum-labs/earnproof-frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.ts
More file actions
162 lines (142 loc) · 4.45 KB
/
Copy pathclient.ts
File metadata and controls
162 lines (142 loc) · 4.45 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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import { appConfig } from "@/config/app";
const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
type ApiClientOptions = RequestInit & {
path: string;
timeoutMs?: number;
};
/**
* Executes a fetch with:
* - Bounded timeout (AbortController with timeout)
* - Caller-provided signal for cancellation (navigation/unmount)
* - Combined signal: request aborts if either times out OR caller cancels
*/
export async function fetchWithTimeout(
url: string,
options: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const {
timeoutMs = DEFAULT_TIMEOUT_MS,
signal: callerSignal,
...rest
} = options;
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
// Combine caller signal + timeout signal
let signal: AbortSignal;
if (callerSignal) {
signal = AbortSignal.any([callerSignal, timeoutController.signal]);
} else {
signal = timeoutController.signal;
}
try {
const response = await fetch(url, { ...rest, signal });
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Retry policy for READ requests only.
* Mutations are NEVER retried automatically — see retryRead() vs retryMutation().
*
* Retryable: network errors, 429, 503, 504
* Not retryable: 4xx (except 429), aborted requests, mutations
*/
export function isRetryable(error: unknown, response?: Response): boolean {
// Never retry aborted requests (user navigated away, unmounted)
if (error instanceof DOMException && error.name === "AbortError") {
return false;
}
// Never retry if no response but error is not a network error
if (response) {
if (response.status === 429) return true; // rate limited — retry with backoff
if (response.status === 503 || response.status === 504) return true;
if (response.status >= 400 && response.status < 500) return false; // 4xx not retryable
}
// Network failure (no response)
if (!response && error instanceof TypeError) {
return true;
}
return false;
}
export async function retryRead<T>(
fn: (signal: AbortSignal) => Promise<T>,
signal: AbortSignal,
maxAttempts = 3,
baseDelayMs = 500,
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn(signal);
} catch (error) {
const isLast = attempt === maxAttempts - 1;
// Don't retry if caller already aborted, or error is not retryable
if (signal.aborted || !isRetryable(error)) {
throw error;
}
if (isLast) {
throw error;
}
// Exponential backoff with jitter
const delay =
baseDelayMs *
Math.pow(2, attempt) *
(0.5 + Math.random() * 0.5);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("retryRead: unreachable");
}
/**
* Mutations are never retried — idempotency must be guaranteed externally
*/
export async function retryMutation<T>(
fn: (signal: AbortSignal) => Promise<T>,
signal: AbortSignal,
): Promise<T> {
return fn(signal); // single attempt, no retry
}
export async function apiClient<TResponse>({
path,
headers,
timeoutMs = DEFAULT_TIMEOUT_MS,
...init
}: ApiClientOptions): Promise<TResponse> {
const response = await fetch(`${appConfig.apiUrl}${path}`, {
...init,
// Every response through this client is either wallet-authenticated,
// payment/proof data, or a verification lookup — none of it is safe
// for Next.js's fetch data cache, a browser HTTP cache, or a shared
// intermediary cache to store or replay. `cache: "no-store"` opts the
// request itself out of Next's fetch cache; the explicit request
// header is a defense-in-depth signal for any caching proxy sitting in
// front of the API that respects request Cache-Control. See
// docs/cache-policy.md.
cache: "no-store",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
...headers,
const response = await fetchWithTimeout(
`${appConfig.apiUrl}${path}`,
{
...init,
timeoutMs,
headers: {
"Content-Type": "application/json",
...headers,
},
},
);
if (!response.ok) {
throw new Error(`EarnProof API request failed with ${response.status}`);
}
return response.json() as Promise<TResponse>;
}
export function bearer(token: string) {
return {
Authorization: `Bearer ${token}`,
};
}