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.
This commit is contained in:
kikootwo
2026-01-16 20:25:05 -05:00
parent dac9183797
commit 2d9ed5c76a
2 changed files with 83 additions and 28 deletions
+75 -26
View File
@@ -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<any> {
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) * Get popular audiobooks from best sellers (with pagination support)
*/ */
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> { async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
await this.initialize(); await this.initialize();
try { logger.info(` Fetching popular audiobooks (limit: ${limit})...`);
logger.info(` Fetching popular audiobooks (limit: ${limit})...`);
const audiobooks: AudibleAudiobook[] = []; const audiobooks: AudibleAudiobook[] = [];
let page = 1; let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page 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}...`); logger.info(` Fetching page ${page}/${maxPages}...`);
const response = await this.client.get('/adblbestsellers', { const response = await this.fetchWithRetry('/adblbestsellers', {
params: page > 1 ? { page } : {}, params: page > 1 ? { page } : {},
}); });
const $ = cheerio.load(response.data); const $ = cheerio.load(response.data);
@@ -192,14 +233,18 @@ export class AudibleService {
if (page <= maxPages && audiobooks.length < limit) { if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500); 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<AudibleAudiobook[]> { async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
await this.initialize(); await this.initialize();
try { logger.info(` Fetching new releases (limit: ${limit})...`);
logger.info(` Fetching new releases (limit: ${limit})...`);
const audiobooks: AudibleAudiobook[] = []; const audiobooks: AudibleAudiobook[] = [];
let page = 1; let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page 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}...`); logger.info(` Fetching page ${page}/${maxPages}...`);
const response = await this.client.get('/newreleases', { const response = await this.fetchWithRetry('/newreleases', {
params: page > 1 ? { page } : {}, params: page > 1 ? { page } : {},
}); });
const $ = cheerio.load(response.data); const $ = cheerio.load(response.data);
@@ -279,14 +324,18 @@ export class AudibleService {
if (page <= maxPages && audiobooks.length < limit) { if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500); 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;
} }
/** /**
+8 -2
View File
@@ -355,7 +355,10 @@ describe('AudibleService', () => {
it('returns empty popular audiobooks on errors', async () => { it('returns empty popular audiobooks on errors', async () => {
configServiceMock.getAudibleRegion.mockResolvedValue('us'); 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 service = new AudibleService();
const results = await service.getPopularAudiobooks(5); const results = await service.getPopularAudiobooks(5);
@@ -365,7 +368,10 @@ describe('AudibleService', () => {
it('returns empty new releases on errors', async () => { it('returns empty new releases on errors', async () => {
configServiceMock.getAudibleRegion.mockResolvedValue('us'); 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 service = new AudibleService();
const results = await service.getNewReleases(5); const results = await service.getNewReleases(5);