Skip to content

Commit 62c010a

Browse files
author
root
committed
fix(wiki): HTTP 429 error handling in wiki-mapper with retry logic and cache fallback
- Add exponential backoff retry logic for HTTP 429 rate limit errors - Implement fallback to cached data when downloads fail - Improve error handling in ETag check HEAD requests - Prevent fatal startup errors when GitHub rate limits occur - Only throw errors if both download and cache read fail
1 parent 2a0eaa5 commit 62c010a

File tree

1 file changed

+116
-26
lines changed

1 file changed

+116
-26
lines changed

addon/lib/wiki-mapper.ts

Lines changed: 116 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,44 +29,132 @@ const moviesTmdbToAll = new Map<number, IdMap>();
2929

3030
let isInitialized = false;
3131

32-
async function downloadCsv(url: string, cachePath: string, etagKey: string): Promise<string> {
32+
async function downloadCsv(url: string, cachePath: string, etagKey: string, maxRetries: number = 3): Promise<string> {
3333
// Check ETag first
3434
if (redis && redis.status === 'ready') {
3535
try {
3636
const savedEtag = await redis.get(etagKey);
3737
if (savedEtag && fs.existsSync(cachePath)) {
38-
const { statusCode, headers } = await request(url, { method: 'HEAD' });
39-
const remoteEtag = headers.etag;
40-
if (savedEtag === remoteEtag) {
41-
console.log(`[Wiki Mapper] Using cache: ${cachePath}`);
42-
return fs.readFileSync(cachePath, 'utf8');
38+
try {
39+
const { statusCode, headers } = await request(url, { method: 'HEAD' });
40+
if (statusCode === 200 && headers.etag) {
41+
const remoteEtag = headers.etag;
42+
if (savedEtag === remoteEtag) {
43+
console.log(`[Wiki Mapper] Using cache: ${cachePath}`);
44+
return fs.readFileSync(cachePath, 'utf8');
45+
}
46+
} else if (statusCode === 429) {
47+
// Rate limited on HEAD request, use cached data
48+
console.warn(`[Wiki Mapper] Rate limited (429) on ETag check, using cached data: ${cachePath}`);
49+
return fs.readFileSync(cachePath, 'utf8');
50+
}
51+
} catch (error: any) {
52+
// If HEAD request fails (e.g., network error, 429), check if we have cached data to use
53+
const statusCode = error.statusCode || (error.response && error.response.statusCode);
54+
if (statusCode === 429 || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code?.startsWith('UND_ERR_')) {
55+
console.warn(`[Wiki Mapper] ETag check failed (${statusCode || error.code}), attempting to use cached data`);
56+
if (fs.existsSync(cachePath)) {
57+
try {
58+
const cachedData = fs.readFileSync(cachePath, 'utf8');
59+
console.log(`[Wiki Mapper] Using cached data due to download error: ${cachePath}`);
60+
return cachedData;
61+
} catch (cacheError: any) {
62+
console.warn(`[Wiki Mapper] Cached data unreadable: ${cacheError.message}`);
63+
}
64+
}
65+
}
66+
console.warn(`[Wiki Mapper] ETag check failed: ${error.message}`);
4367
}
4468
}
45-
} catch (error) {
69+
} catch (error: any) {
4670
console.warn(`[Wiki Mapper] ETag check failed: ${error.message}`);
4771
}
4872
}
4973

50-
// Download fresh data using undici for speed
51-
console.log(`[Wiki Mapper] Downloading: ${url}`);
52-
const { statusCode, headers, body } = await request(url);
74+
// Download fresh data using undici with retry logic for 429 errors
75+
let lastError: Error | null = null;
5376

54-
if (statusCode < 200 || statusCode >= 300) {
55-
throw new Error(`HTTP ${statusCode}`);
56-
}
57-
58-
const csvData = await body.text();
77+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
78+
try {
79+
if (attempt > 0) {
80+
// Exponential backoff: 1s, 2s, 4s, 8s... (capped at 30s)
81+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
82+
console.log(`[Wiki Mapper] Retrying download (attempt ${attempt + 1}/${maxRetries + 1}) after ${delay}ms: ${url}`);
83+
await new Promise(resolve => setTimeout(resolve, delay));
84+
} else {
85+
console.log(`[Wiki Mapper] Downloading: ${url}`);
86+
}
87+
88+
const { statusCode, headers, body } = await request(url);
89+
90+
if (statusCode === 429) {
91+
// Rate limited - will retry if attempts remain
92+
lastError = new Error(`HTTP ${statusCode}`);
93+
if (attempt < maxRetries) {
94+
continue;
95+
}
96+
// Last attempt failed with 429, try to use cache
97+
console.warn(`[Wiki Mapper] Rate limited (429) after ${maxRetries + 1} attempts, falling back to cache`);
98+
if (fs.existsSync(cachePath)) {
99+
try {
100+
const cachedData = fs.readFileSync(cachePath, 'utf8');
101+
console.log(`[Wiki Mapper] Using cached data after rate limit: ${cachePath}`);
102+
return cachedData;
103+
} catch (cacheError: any) {
104+
console.error(`[Wiki Mapper] Cached data unreadable: ${cacheError.message}`);
105+
throw lastError;
106+
}
107+
}
108+
throw lastError;
109+
}
110+
111+
if (statusCode < 200 || statusCode >= 300) {
112+
throw new Error(`HTTP ${statusCode}`);
113+
}
114+
115+
const csvData = await body.text();
59116

60-
// Save to cache
61-
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
62-
fs.writeFileSync(cachePath, csvData);
117+
// Save to cache
118+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
119+
fs.writeFileSync(cachePath, csvData);
63120

64-
// Save ETag
65-
if (redis && redis.status === 'ready' && headers.etag) {
66-
await redis.set(etagKey, headers.etag);
67-
}
121+
// Save ETag
122+
if (redis && redis.status === 'ready' && headers.etag) {
123+
await redis.set(etagKey, headers.etag);
124+
}
68125

69-
return csvData;
126+
return csvData;
127+
} catch (error: any) {
128+
lastError = error;
129+
130+
// Check if it's a 429 error (either from statusCode check above or from error message)
131+
const is429 = error.message?.includes('HTTP 429') ||
132+
error.statusCode === 429 ||
133+
(error.response && error.response.statusCode === 429);
134+
135+
// If it's a 429 and we have retries left, continue the loop
136+
if (is429 && attempt < maxRetries) {
137+
continue;
138+
}
139+
140+
// If it's not a 429 or we're out of retries, try to use cached data
141+
if (fs.existsSync(cachePath)) {
142+
try {
143+
console.warn(`[Wiki Mapper] Download failed (${error.message}), falling back to cached data: ${cachePath}`);
144+
const cachedData = fs.readFileSync(cachePath, 'utf8');
145+
return cachedData;
146+
} catch (cacheError: any) {
147+
console.error(`[Wiki Mapper] Cached data also unreadable: ${cacheError.message}`);
148+
}
149+
}
150+
151+
// If no cache available or cache read failed, throw the original error
152+
throw error;
153+
}
154+
}
155+
156+
// Should never reach here, but TypeScript needs it
157+
throw lastError || new Error('Download failed after all retries');
70158
}
71159

72160
function loadMappings(csvData: string, maps: { imdb: Map<string, IdMap>, tvdb: Map<number, IdMap>, tmdb: Map<number, IdMap>, tvmaze?: Map<number, IdMap> }) {
@@ -155,9 +243,11 @@ async function initialize() {
155243

156244
isInitialized = true;
157245
console.log('[Wiki Mapper] Initialization complete');
158-
} catch (error) {
159-
console.error('[Wiki Mapper] Initialization failed:', error);
160-
throw error;
246+
} catch (error: any) {
247+
const errorMessage = error?.message || String(error);
248+
console.error(`[Wiki Mapper] Initialization failed: ${errorMessage}`);
249+
// Re-throw with a more descriptive error message
250+
throw new Error(`Wiki Mappings failed to initialize: ${errorMessage}`);
161251
}
162252
}
163253

0 commit comments

Comments
 (0)