From 2d9ed5c76aec22d6af589c118676248f16b6bc36 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 16 Jan 2026 20:25:05 -0500 Subject: [PATCH] Add retry logic with exponential backoff to AudibleService Introduces a fetchWithRetry method to handle network errors and rate limiting (503, 429) with exponential backoff in AudibleService. Updates getPopularAudiobooks and getNewReleases to use this retry logic and improves error handling to stop pagination on errors but return collected results. Test cases are updated to use non-retryable errors (404) for more accurate coverage. --- src/lib/integrations/audible.service.ts | 101 +++++++++++++++------ tests/integrations/audible.service.test.ts | 10 +- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 5031245..ccc30aa 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -114,23 +114,64 @@ export class AudibleService { } } + /** + * Fetch with retry logic and exponential backoff + * Retries on network errors and rate limiting (503, 429) + */ + private async fetchWithRetry( + url: string, + config: any = {}, + maxRetries: number = 3 + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await this.client.get(url, config); + } catch (error: any) { + lastError = error; + const status = error.response?.status; + const isRetryable = !status || status === 503 || status === 429 || status >= 500; + + // Don't retry on 404, 403, etc. + if (!isRetryable) { + throw error; + } + + // Don't retry on last attempt + if (attempt === maxRetries) { + break; + } + + // Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, 8s...) + const backoffMs = Math.pow(2, attempt) * 1000; + logger.info(` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`); + + await this.delay(backoffMs); + } + } + + // All retries exhausted + throw lastError || new Error('Request failed after retries'); + } + /** * Get popular audiobooks from best sellers (with pagination support) */ async getPopularAudiobooks(limit: number = 20): Promise { await this.initialize(); - try { - logger.info(` Fetching popular audiobooks (limit: ${limit})...`); + logger.info(` Fetching popular audiobooks (limit: ${limit})...`); - const audiobooks: AudibleAudiobook[] = []; - let page = 1; - const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page + const audiobooks: AudibleAudiobook[] = []; + let page = 1; + const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page - while (audiobooks.length < limit && page <= maxPages) { + while (audiobooks.length < limit && page <= maxPages) { + try { logger.info(` Fetching page ${page}/${maxPages}...`); - const response = await this.client.get('/adblbestsellers', { + const response = await this.fetchWithRetry('/adblbestsellers', { params: page > 1 ? { page } : {}, }); const $ = cheerio.load(response.data); @@ -192,14 +233,18 @@ export class AudibleService { if (page <= maxPages && audiobooks.length < limit) { await this.delay(1500); } + } catch (error) { + logger.error(`Failed to fetch page ${page} of popular audiobooks`, { + error: error instanceof Error ? error.message : String(error), + collectedSoFar: audiobooks.length + }); + // Stop pagination on error, but return what we collected + break; } - - logger.info(` Found ${audiobooks.length} popular audiobooks across ${page} pages`); - return audiobooks; - } catch (error) { - logger.error('Failed to fetch popular audiobooks', { error: error instanceof Error ? error.message : String(error) }); - return []; } + + logger.info(` Found ${audiobooks.length} popular audiobooks across ${page - 1} pages`); + return audiobooks; } /** @@ -208,17 +253,17 @@ export class AudibleService { async getNewReleases(limit: number = 20): Promise { await this.initialize(); - try { - logger.info(` Fetching new releases (limit: ${limit})...`); + logger.info(` Fetching new releases (limit: ${limit})...`); - const audiobooks: AudibleAudiobook[] = []; - let page = 1; - const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page + const audiobooks: AudibleAudiobook[] = []; + let page = 1; + const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page - while (audiobooks.length < limit && page <= maxPages) { + while (audiobooks.length < limit && page <= maxPages) { + try { logger.info(` Fetching page ${page}/${maxPages}...`); - const response = await this.client.get('/newreleases', { + const response = await this.fetchWithRetry('/newreleases', { params: page > 1 ? { page } : {}, }); const $ = cheerio.load(response.data); @@ -279,14 +324,18 @@ export class AudibleService { if (page <= maxPages && audiobooks.length < limit) { await this.delay(1500); } + } catch (error) { + logger.error(`Failed to fetch page ${page} of new releases`, { + error: error instanceof Error ? error.message : String(error), + collectedSoFar: audiobooks.length + }); + // Stop pagination on error, but return what we collected + break; } - - logger.info(` Found ${audiobooks.length} new releases across ${page} pages`); - return audiobooks; - } catch (error) { - logger.error('Failed to fetch new releases', { error: error instanceof Error ? error.message : String(error) }); - return []; } + + logger.info(` Found ${audiobooks.length} new releases across ${page - 1} pages`); + return audiobooks; } /** diff --git a/tests/integrations/audible.service.test.ts b/tests/integrations/audible.service.test.ts index a3cdef5..761f5c6 100644 --- a/tests/integrations/audible.service.test.ts +++ b/tests/integrations/audible.service.test.ts @@ -355,7 +355,10 @@ describe('AudibleService', () => { it('returns empty popular audiobooks on errors', async () => { configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get.mockRejectedValue(new Error('boom')); + // Use 404 error which is not retryable + const error: any = new Error('Not Found'); + error.response = { status: 404 }; + clientMock.get.mockRejectedValue(error); const service = new AudibleService(); const results = await service.getPopularAudiobooks(5); @@ -365,7 +368,10 @@ describe('AudibleService', () => { it('returns empty new releases on errors', async () => { configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get.mockRejectedValue(new Error('boom')); + // Use 404 error which is not retryable + const error: any = new Error('Not Found'); + error.response = { status: 404 }; + clientMock.get.mockRejectedValue(error); const service = new AudibleService(); const results = await service.getNewReleases(5);