@xiboplayer/utils 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cms-api.js +801 -1
- package/src/cms-api.test.js +912 -0
- package/src/config.js +4 -0
- package/src/fetch-retry.js +46 -1
- package/src/fetch-retry.test.js +106 -0
package/src/config.js
CHANGED
|
@@ -25,6 +25,7 @@ function loadFromEnv() {
|
|
|
25
25
|
displayName: env.DISPLAY_NAME || '',
|
|
26
26
|
hardwareKey: env.HARDWARE_KEY || '',
|
|
27
27
|
xmrChannel: env.XMR_CHANNEL || '',
|
|
28
|
+
googleGeoApiKey: env.GOOGLE_GEO_API_KEY || '',
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
// Return env config if any value is set
|
|
@@ -322,6 +323,9 @@ export class Config {
|
|
|
322
323
|
get xmrChannel() { return this.data.xmrChannel; }
|
|
323
324
|
get xmrPubKey() { return this.data.xmrPubKey || ''; }
|
|
324
325
|
get xmrPrivKey() { return this.data.xmrPrivKey || ''; }
|
|
326
|
+
|
|
327
|
+
get googleGeoApiKey() { return this.data.googleGeoApiKey || ''; }
|
|
328
|
+
set googleGeoApiKey(val) { this.data.googleGeoApiKey = val; this.save(); }
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
export const config = new Config();
|
package/src/fetch-retry.js
CHANGED
|
@@ -10,6 +10,37 @@ import { createLogger } from './logger.js';
|
|
|
10
10
|
|
|
11
11
|
const log = createLogger('FetchRetry');
|
|
12
12
|
|
|
13
|
+
const DEFAULT_429_DELAY_MS = 30000;
|
|
14
|
+
const MAX_429_DELAY_MS = 120000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a Retry-After header value into milliseconds.
|
|
18
|
+
* Supports both delta-seconds ("120") and HTTP-date ("Fri, 21 Feb 2026 12:00:00 GMT").
|
|
19
|
+
* Returns a sensible default if the header is missing or unparseable.
|
|
20
|
+
* The returned delay is NOT capped by maxDelayMs — the server's rate-limit
|
|
21
|
+
* instruction takes priority over our backoff ceiling.
|
|
22
|
+
* @param {string|null} headerValue
|
|
23
|
+
* @returns {number} delay in milliseconds (clamped to MAX_429_DELAY_MS)
|
|
24
|
+
*/
|
|
25
|
+
function parseRetryAfter(headerValue) {
|
|
26
|
+
if (!headerValue) return DEFAULT_429_DELAY_MS;
|
|
27
|
+
|
|
28
|
+
// Try delta-seconds first (most common)
|
|
29
|
+
const seconds = Number(headerValue);
|
|
30
|
+
if (!isNaN(seconds) && seconds >= 0) {
|
|
31
|
+
return Math.min(seconds * 1000, MAX_429_DELAY_MS);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Try HTTP-date format (RFC 7231 §7.1.3)
|
|
35
|
+
const date = new Date(headerValue);
|
|
36
|
+
if (!isNaN(date.getTime())) {
|
|
37
|
+
const delayMs = date.getTime() - Date.now();
|
|
38
|
+
return Math.min(Math.max(delayMs, 0), MAX_429_DELAY_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return DEFAULT_429_DELAY_MS;
|
|
42
|
+
}
|
|
43
|
+
|
|
13
44
|
/**
|
|
14
45
|
* Fetch with automatic retry on failure
|
|
15
46
|
* @param {string|URL} url - URL to fetch
|
|
@@ -29,7 +60,21 @@ export async function fetchWithRetry(url, options = {}, retryOptions = {}) {
|
|
|
29
60
|
try {
|
|
30
61
|
const response = await fetch(url, options);
|
|
31
62
|
|
|
32
|
-
//
|
|
63
|
+
// HTTP 429 Too Many Requests — respect Retry-After header
|
|
64
|
+
if (response.status === 429) {
|
|
65
|
+
const delayMs = parseRetryAfter(response.headers.get('Retry-After'));
|
|
66
|
+
log.debug(`429 Rate limited, waiting ${delayMs}ms (Retry-After: ${response.headers.get('Retry-After')})`);
|
|
67
|
+
lastResponse = response;
|
|
68
|
+
lastError = new Error(`HTTP 429: Too Many Requests`);
|
|
69
|
+
lastError.status = 429;
|
|
70
|
+
if (attempt < maxRetries) {
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
72
|
+
continue; // Skip the normal backoff delay below
|
|
73
|
+
}
|
|
74
|
+
break; // Exhausted retries
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Don't retry other client errors (4xx) — they won't change with retries
|
|
33
78
|
if (response.ok || (response.status >= 400 && response.status < 500)) {
|
|
34
79
|
return response;
|
|
35
80
|
}
|
package/src/fetch-retry.test.js
CHANGED
|
@@ -105,4 +105,110 @@ describe('fetchWithRetry', () => {
|
|
|
105
105
|
expect(response.status).toBe(500);
|
|
106
106
|
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
it('should retry on HTTP 429 with Retry-After header', async () => {
|
|
110
|
+
const headers429 = { get: (name) => name === 'Retry-After' ? '1' : null };
|
|
111
|
+
mockFetch
|
|
112
|
+
.mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Too Many Requests', headers: headers429 })
|
|
113
|
+
.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
114
|
+
|
|
115
|
+
const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 2, baseDelayMs: 100 });
|
|
116
|
+
|
|
117
|
+
// Advance past the 1s Retry-After delay
|
|
118
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
119
|
+
|
|
120
|
+
const response = await promise;
|
|
121
|
+
expect(response.ok).toBe(true);
|
|
122
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return 429 response when retries exhausted', async () => {
|
|
126
|
+
const headers429 = { get: (name) => name === 'Retry-After' ? '1' : null };
|
|
127
|
+
mockFetch.mockResolvedValue({ ok: false, status: 429, statusText: 'Too Many Requests', headers: headers429 });
|
|
128
|
+
|
|
129
|
+
const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 1, baseDelayMs: 100 });
|
|
130
|
+
|
|
131
|
+
// Advance past the Retry-After delay
|
|
132
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
133
|
+
|
|
134
|
+
const response = await promise;
|
|
135
|
+
expect(response.status).toBe(429);
|
|
136
|
+
expect(mockFetch).toHaveBeenCalledTimes(2); // 1 original + 1 retry
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should parse Retry-After as HTTP-date', async () => {
|
|
140
|
+
// Set a date 2 seconds in the future
|
|
141
|
+
const futureDate = new Date(Date.now() + 2000).toUTCString();
|
|
142
|
+
const headers429 = { get: (name) => name === 'Retry-After' ? futureDate : null };
|
|
143
|
+
mockFetch
|
|
144
|
+
.mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Too Many Requests', headers: headers429 })
|
|
145
|
+
.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
146
|
+
|
|
147
|
+
const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 2, baseDelayMs: 100 });
|
|
148
|
+
|
|
149
|
+
// Advance past the ~2s date-based delay
|
|
150
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
151
|
+
|
|
152
|
+
const response = await promise;
|
|
153
|
+
expect(response.ok).toBe(true);
|
|
154
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should not cap 429 delay at maxDelayMs', async () => {
|
|
158
|
+
// Server asks for 5s, but maxDelayMs is only 1s — 429 should still wait 5s
|
|
159
|
+
const headers429 = { get: (name) => name === 'Retry-After' ? '5' : null };
|
|
160
|
+
mockFetch
|
|
161
|
+
.mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Too Many Requests', headers: headers429 })
|
|
162
|
+
.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
163
|
+
|
|
164
|
+
const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 2, baseDelayMs: 100, maxDelayMs: 1000 });
|
|
165
|
+
|
|
166
|
+
// 1s is not enough — should still be waiting
|
|
167
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
168
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
169
|
+
|
|
170
|
+
// 5s total should be enough
|
|
171
|
+
await vi.advanceTimersByTimeAsync(4500);
|
|
172
|
+
|
|
173
|
+
const response = await promise;
|
|
174
|
+
expect(response.ok).toBe(true);
|
|
175
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should use default 30s delay when Retry-After header is missing on 429', async () => {
|
|
179
|
+
const headers429 = { get: () => null };
|
|
180
|
+
mockFetch
|
|
181
|
+
.mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Too Many Requests', headers: headers429 })
|
|
182
|
+
.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
183
|
+
|
|
184
|
+
const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 2, baseDelayMs: 100 });
|
|
185
|
+
|
|
186
|
+
// 10s is not enough — default is 30s
|
|
187
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
188
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
189
|
+
|
|
190
|
+
// 30s total should be enough
|
|
191
|
+
await vi.advanceTimersByTimeAsync(21000);
|
|
192
|
+
|
|
193
|
+
const response = await promise;
|
|
194
|
+
expect(response.ok).toBe(true);
|
|
195
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should handle Retry-After date in the past as immediate retry', async () => {
|
|
199
|
+
const pastDate = new Date(Date.now() - 5000).toUTCString();
|
|
200
|
+
const headers429 = { get: (name) => name === 'Retry-After' ? pastDate : null };
|
|
201
|
+
mockFetch
|
|
202
|
+
.mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Too Many Requests', headers: headers429 })
|
|
203
|
+
.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
204
|
+
|
|
205
|
+
const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 2, baseDelayMs: 100 });
|
|
206
|
+
|
|
207
|
+
// Past date means delay=0, should retry immediately
|
|
208
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
209
|
+
|
|
210
|
+
const response = await promise;
|
|
211
|
+
expect(response.ok).toBe(true);
|
|
212
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
213
|
+
});
|
|
108
214
|
});
|