@xiboplayer/utils 0.3.7 → 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/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();
@@ -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
- // Don't retry client errors (4xx)they won't change with retries
63
+ // HTTP 429 Too Many Requestsrespect 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
  }
@@ -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
  });