diff --git a/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts b/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts index c487f8743..5a2b769aa 100644 --- a/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts +++ b/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts @@ -4,7 +4,7 @@ import { NotificationType, UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES, WORKER_WRIT import type { RequestUser } from '../../../common/types/request-user'; import type { BookRequestDownloadRow, BookRequestRow } from '../../../db/schema'; import { newznabClientKey } from '../indexers/adapters/newznab.adapter'; -import { IndexerSearchException } from '../indexers/indexer-adapter'; +import { IndexerRetryExhaustedException, IndexerSearchException } from '../indexers/indexer-adapter'; import { RequestFulfillmentService } from './request-fulfillment.service'; const INFO_HASH = 'c9e15763f722f23e98a29decdfae341b98d53056'; @@ -468,6 +468,24 @@ describe('RequestFulfillmentService.grab from a picked release', () => { ); }); + it.each([true, false])('does not restart an exhausted NZB retry budget (automated=%s)', async (automated) => { + const exhausted = new IndexerRetryExhaustedException('throttled', 'The indexer is still rate limiting us'); + const fetchNzbFile = vi.fn().mockRejectedValue(exhausted); + const { service, downloads, adapter } = makeService({ + releases: { find: vi.fn().mockReturnValue(RELEASE) }, + indexers: { resolveConfig: vi.fn().mockResolvedValue({ id: 9, name: 'usenet', adapterType: 'newznab' }) }, + indexerAdapter: { fetchNzbFile }, + }); + + await expect(service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, automated ? null : user())).rejects.toMatchObject({ + response: { errorCode: 'GRAB_SOURCE_UNAVAILABLE', statusCode: 503 }, + cause: exhausted, + }); + expect(fetchNzbFile).toHaveBeenCalledTimes(1); + expect(downloads.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: 'nzb_file', status: 'failed', automated })); + expect(adapter.add).not.toHaveBeenCalled(); + }); + it('inspects the torrent without creating an attempt and reuses it for the following grab', async () => { const { service, downloads, adapter, indexerAdapter } = makeService({ releases: { find: vi.fn().mockReturnValue(RELEASE) }, diff --git a/server/src/modules/book-request/fulfillment/request-fulfillment.service.ts b/server/src/modules/book-request/fulfillment/request-fulfillment.service.ts index 74515ffa5..426332555 100644 --- a/server/src/modules/book-request/fulfillment/request-fulfillment.service.ts +++ b/server/src/modules/book-request/fulfillment/request-fulfillment.service.ts @@ -58,6 +58,7 @@ import { DownloadClientRegistry } from '../download-clients/download-client-regi import { ADD_PATH_MAPPING_HINT } from '../download-clients/path-mapping.service'; import { IndexerSearchException, + IndexerRetryExhaustedException, type IndexerAdapter, type ReleaseCandidate, type ReleaseFile, @@ -345,7 +346,9 @@ export class RequestFulfillmentService { try { return await run(); } catch (error) { - if (!automated || !isTransientGrabFailure(error)) throw error; + if (!automated || !isTransientGrabFailure(error) || (error instanceof HttpException && error.cause instanceof IndexerRetryExhaustedException)) { + throw error; + } const code = grabFailureCode(error) ?? (phase === 'client' ? 'GRAB_CLIENT_UNAVAILABLE' : 'GRAB_SOURCE_UNAVAILABLE'); this.logger.warn( @@ -857,7 +860,10 @@ async function resolveReleaseFile(adapter: IndexerAdapter, release: ReleaseCandi */ function indexerRefusal(error: IndexerSearchException, release: ReleaseCandidate): HttpException { if (RETRYABLE_INDEXER_FAILURES.includes(error.failure)) { - return grabUnavailable('GRAB_SOURCE_UNAVAILABLE', error.message); + return new ServiceUnavailableException( + { message: error.message, errorCode: 'GRAB_SOURCE_UNAVAILABLE' satisfies GrabFailureCode, statusCode: HttpStatus.SERVICE_UNAVAILABLE }, + { cause: error }, + ); } return grabError(release.vipOnly === true ? 'GRAB_VIP_REQUIRED' : 'GRAB_SOURCE_REFUSED', error.message); } diff --git a/server/src/modules/book-request/indexers/adapters/newznab.adapter.ts b/server/src/modules/book-request/indexers/adapters/newznab.adapter.ts index d93219490..07dbcba8e 100644 --- a/server/src/modules/book-request/indexers/adapters/newznab.adapter.ts +++ b/server/src/modules/book-request/indexers/adapters/newznab.adapter.ts @@ -11,6 +11,7 @@ import { ensureSafeUrl, PrivateAddressException } from '../../../../common/utils import { buildSearchText } from '../search-text'; import { IndexerSearchException, + IndexerRetryExhaustedException, type IndexerAdapter, type ReleaseCandidate, type ReleaseQuery, @@ -23,6 +24,9 @@ const MAX_FEED_ITEMS = 500; const MAX_FEED_FIELD_CHARS = MAX_INDEXER_RELEASE_GUID_LENGTH; const REQUEST_TIMEOUT_MS = 25_000; const MAX_REDIRECTS = 5; +const MAX_RATE_LIMIT_RETRIES = 2; +const MAX_RATE_LIMIT_DELAY_MS = 10_000; +const DEFAULT_RATE_LIMIT_DELAY_MS = 1000; const XML_ACCEPT = 'application/rss+xml, application/xml, text/xml, */*;q=0.1'; const NZB_ACCEPT = 'application/x-nzb, application/xml, text/xml, */*;q=0.1'; @@ -88,12 +92,9 @@ export class NewznabAdapter implements IndexerAdapter { try { let current = await ensureSafeUrl(release.downloadUrl, { allowPrivate: config.allowPrivateAddress }); const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const retries = { remaining: MAX_RATE_LIMIT_RETRIES }; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { - const response = await safeFetch( - current.href, - { signal, redirect: 'manual', headers: { Accept: NZB_ACCEPT } }, - { allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true }, - ); + const response = await fetchWithRateLimitRetry(current.href, config, NZB_ACCEPT, signal, retries); if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); @@ -103,7 +104,10 @@ export class NewznabAdapter implements IndexerAdapter { continue; } - if (!response.ok) throw new IndexerSearchException('error', `The indexer answered ${response.status} for that NZB`); + if (!response.ok) { + void response.body?.cancel().catch(() => undefined); + throw new IndexerSearchException('error', `The indexer answered ${response.status} for that NZB`); + } const body = await readBounded(response, MAX_NZB_FILE_BYTES, 'The indexer returned an NZB that is too large'); assertNzb(body); return body; @@ -127,11 +131,13 @@ export class NewznabAdapter implements IndexerAdapter { const options = { allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true }; let current = await endpointUrl(config, params); let response: Response | null = null; + const retries = { remaining: MAX_RATE_LIMIT_RETRIES }; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { try { - response = await safeFetch(current.href, { signal, redirect: 'manual', headers: { Accept: XML_ACCEPT } }, options); + response = await fetchWithRateLimitRetry(current.href, config, XML_ACCEPT, signal, retries); } catch (error) { + if (error instanceof IndexerSearchException) throw error; if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { throw new IndexerSearchException('timeout', `${config.name} did not answer in time`); } @@ -147,7 +153,7 @@ export class NewznabAdapter implements IndexerAdapter { } if (!response) throw new IndexerSearchException('error', `${config.name} redirected more than ${MAX_REDIRECTS} times`); - if (response.status === 429) throw new IndexerSearchException('throttled', `${config.name} is rate limiting us`); + if (!response.ok) void response.body?.cancel().catch(() => undefined); if (response.status === 401 || response.status === 403) throw new IndexerSearchException('unauthorized', `${config.name} rejected the API key`); if (!response.ok) throw new IndexerSearchException('error', `${config.name} answered ${response.status}`); @@ -160,6 +166,74 @@ export class NewznabAdapter implements IndexerAdapter { } } +async function fetchWithRateLimitRetry( + url: string, + config: ResolvedIndexerConfig, + accept: string, + signal: AbortSignal, + retries: { remaining: number }, +): Promise { + for (;;) { + let response: Response; + try { + signal.throwIfAborted(); + response = await safeFetch( + url, + { signal, redirect: 'manual', headers: { Accept: accept } }, + { allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true }, + ); + } catch (error) { + if (signal.aborted && retries.remaining < MAX_RATE_LIMIT_RETRIES) { + throw new IndexerRetryExhaustedException('throttled', `${config.name} is rate limiting us beyond the request deadline`); + } + throw error; + } + if (response.status !== 429) return response; + + const delayMs = rateLimitDelay(response.headers.get('retry-after')); + void response.body?.cancel().catch(() => undefined); + if (retries.remaining === 0) { + throw new IndexerRetryExhaustedException('throttled', `${config.name} is rate limiting us after ${MAX_RATE_LIMIT_RETRIES} retries`); + } + retries.remaining--; + try { + await waitForRateLimit(delayMs, signal); + } catch (error) { + // Restarting the generic grab retry after the deadline would exceed this bounded wait. + if (signal.aborted) throw new IndexerRetryExhaustedException('throttled', `${config.name} is rate limiting us beyond the request deadline`); + throw error; + } + } +} + +function rateLimitDelay(header: string | null): number { + const value = header?.trim(); + if (!value) return DEFAULT_RATE_LIMIT_DELAY_MS; + if (/^\d+$/.test(value)) return Math.min(Number(value) * 1000, MAX_RATE_LIMIT_DELAY_MS); + // Date.parse also accepts non-HTTP inputs such as "-1" as dates. + const httpDate = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/i; + const obsoleteDate = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-[A-Za-z]{3}-\d{2} \d{2}:\d{2}:\d{2} GMT$/i; + const asctimeDate = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) [A-Za-z]{3} {1,2}\d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/i; + const date = httpDate.test(value) || obsoleteDate.test(value) ? Date.parse(value) : asctimeDate.test(value) ? Date.parse(`${value} GMT`) : NaN; + return Number.isFinite(date) ? Math.min(Math.max(0, date - Date.now()), MAX_RATE_LIMIT_DELAY_MS) : DEFAULT_RATE_LIMIT_DELAY_MS; +} + +async function waitForRateLimit(delayMs: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + await new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + const reason: unknown = signal.reason; + reject(reason instanceof Error ? reason : new DOMException('The indexer wait was aborted', 'AbortError')); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, delayMs); + signal.addEventListener('abort', abort, { once: true }); + }); +} + export function newznabClientKey(indexerId: number, guid: string): string { return createHash('sha256').update(String(indexerId)).update('\0').update(guid).digest('hex'); } diff --git a/server/src/modules/book-request/indexers/adapters/newznab.rate-limit.test.ts b/server/src/modules/book-request/indexers/adapters/newznab.rate-limit.test.ts new file mode 100644 index 000000000..bb358f603 --- /dev/null +++ b/server/src/modules/book-request/indexers/adapters/newznab.rate-limit.test.ts @@ -0,0 +1,281 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { DEFAULT_INDEXER_CATEGORIES } from '@bookorbit/types'; + +vi.mock('../../../../common/utils/safe-fetch', () => ({ safeFetch: vi.fn() })); +vi.mock('node:dns/promises', async (importOriginal) => ({ + ...(await importOriginal()), + lookup: vi.fn(() => Promise.resolve([{ address: '93.184.216.34', family: 4 }])), +})); + +import { safeFetch } from '../../../../common/utils/safe-fetch'; +import { IndexerRetryExhaustedException, type ReleaseCandidate, type ResolvedIndexerConfig } from '../indexer-adapter'; +import { NewznabAdapter } from './newznab.adapter'; + +const NOW = new Date('2026-10-12T12:00:00Z'); +const NZB = Buffer.from(''); +const FEED = + 'Dune EPUBrelease-1https://indexer.example.test/get/1'; +const MODES = ['nzb', 'search'] as const; +type Mode = (typeof MODES)[number]; +const fetchMock = vi.mocked(safeFetch); + +const CONFIG: ResolvedIndexerConfig = { + id: 7, + managerId: null, + managerPriority: null, + name: 'fixture indexer', + color: null, + adapterType: 'newznab', + baseUrl: 'https://indexer.example.test', + credential: 'fixture-key', + credentialError: null, + allowPrivateAddress: false, + applyTrackerSeedGoals: true, + seedRatioGoal: null, + seedTimeMinutes: null, + categories: DEFAULT_INDEXER_CATEGORIES.newznab, + disabledMediaKinds: [], + isbnSearchDisabled: false, + settings: null, + networkProfile: null, + perIndexerTimeoutSeconds: 20, + overallSearchBudgetSeconds: null, + autoExpandCategories: false, +}; +const RELEASE: ReleaseCandidate = { + indexerId: 7, + guid: 'release-1', + title: 'Dune EPUB', + downloadUrl: 'https://indexer.example.test/get/1', + sizeBytes: null, + seeders: null, + leechers: null, +}; + +function throttled(header: string | null = '5'): Response { + return new Response('Rate limited', { status: 429, headers: header === null ? {} : { 'Retry-After': header } }); +} + +describe('NewznabAdapter bounded rate-limit retries', () => { + let module: TestingModule; + let adapter: NewznabAdapter; + let controller: AbortController; + + beforeEach(async () => { + fetchMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + controller = new AbortController(); + module = await Test.createTestingModule({ providers: [NewznabAdapter] }).compile(); + adapter = module.get(NewznabAdapter); + }); + + afterEach(async () => { + await module.close(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + function invoke(mode: Mode) { + return mode === 'nzb' + ? adapter.fetchNzbFile(RELEASE, CONFIG) + : adapter.search( + { + title: 'Dune', + author: null, + isbn13: null, + isbn13s: [], + mediaKind: 'ebook', + language: null, + limit: 50, + }, + CONFIG, + controller.signal, + ); + } + + function success(mode: Mode): Response { + return new Response(mode === 'nzb' ? NZB : FEED); + } + + function expected(mode: Mode) { + return mode === 'nzb' ? NZB : [expect.objectContaining({ guid: 'release-1' })]; + } + + it.each(MODES)('%s waits for Retry-After and releases the 429 body before retrying the same URL', async (mode) => { + const response = throttled(); + const cancel = vi.spyOn(response.body!, 'cancel'); + fetchMock.mockResolvedValueOnce(response).mockResolvedValueOnce(success(mode)); + const outcome = expect(invoke(mode)).resolves.toEqual(expected(mode)); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(4999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await outcome; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]).toEqual(fetchMock.mock.calls[0]); + }); + + const headerCases = [ + { label: 'missing', header: null, delay: 1000 }, + { label: 'empty', header: '', delay: 1000 }, + { label: 'malformed', header: 'tomorrow', delay: 1000 }, + { label: 'negative', header: '-1', delay: 1000 }, + { label: 'fractional', header: '1.5', delay: 1000 }, + { label: 'infinite', header: 'Infinity', delay: 1000 }, + { label: 'zero', header: '0', delay: 0 }, + { label: 'leading zeroes', header: '0002', delay: 2000 }, + { label: 'seconds', header: '5', delay: 5000 }, + { label: 'capped seconds', header: '600', delay: 10_000 }, + { label: 'overflowing seconds', header: '9'.repeat(400), delay: 10_000 }, + { label: 'HTTP date', header: 'Mon, 12 Oct 2026 12:00:05 GMT', delay: 5000 }, + { label: 'RFC850 date', header: 'Monday, 12-Oct-26 12:00:05 GMT', delay: 5000 }, + { label: 'asctime date', header: 'Mon Oct 12 12:00:05 2026', delay: 5000 }, + { label: 'past date', header: 'Mon, 12 Oct 2026 11:59:55 GMT', delay: 0 }, + { label: 'capped date', header: 'Mon, 12 Oct 2026 12:10:00 GMT', delay: 10_000 }, + { label: 'invalid HTTP date', header: 'Mon, 12 Bad 2026 12:00:05 GMT', delay: 1000 }, + ]; + + it.each(MODES.flatMap((mode) => headerCases.map((entry) => ({ mode, ...entry }))))( + '$mode handles $label Retry-After with a bounded delay', + async ({ mode, header, delay }) => { + const times: number[] = []; + fetchMock.mockImplementationOnce(() => { + times.push(Date.now()); + return Promise.resolve(throttled(header)); + }); + fetchMock.mockImplementationOnce(() => { + times.push(Date.now()); + return Promise.resolve(success(mode)); + }); + const outcome = expect(invoke(mode)).resolves.toEqual(expected(mode)); + await vi.advanceTimersByTimeAsync(delay); + await outcome; + expect(times).toEqual([NOW.getTime(), NOW.getTime() + delay]); + }, + ); + + it.each(MODES)('%s recovers on the second retry using the latest Retry-After', async (mode) => { + fetchMock.mockResolvedValueOnce(throttled('5')).mockResolvedValueOnce(throttled('2')).mockResolvedValueOnce(success(mode)); + const outcome = expect(invoke(mode)).resolves.toEqual(expected(mode)); + await vi.advanceTimersByTimeAsync(5000); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1999); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + await outcome; + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it.each(MODES)('%s exhausts after three requests and cancels every discarded response', async (mode) => { + const responses = [throttled(), throttled(), throttled()]; + const cancellations = responses.map((response) => vi.spyOn(response.body!, 'cancel')); + responses.forEach((response) => fetchMock.mockResolvedValueOnce(response)); + const outcome = expect(invoke(mode)).rejects.toBeInstanceOf(IndexerRetryExhaustedException); + await vi.advanceTimersByTimeAsync(10_000); + await outcome; + cancellations.forEach((cancel) => expect(cancel).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it.each(MODES)('%s shares its retry budget across redirects', async (mode) => { + fetchMock + .mockResolvedValueOnce(throttled('0')) + .mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: '/second' } })) + .mockResolvedValueOnce(throttled('0')) + .mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: '/third' } })) + .mockResolvedValueOnce(throttled('0')); + const outcome = expect(invoke(mode)).rejects.toMatchObject({ failure: 'throttled' }); + await vi.runAllTimersAsync(); + await outcome; + expect(fetchMock.mock.calls.map(([url]) => new URL(url).pathname)).toEqual( + mode === 'nzb' ? ['/get/1', '/get/1', '/second', '/second', '/third'] : ['/api', '/api', '/second', '/second', '/third'], + ); + }); + + it.each(MODES.flatMap((mode) => [400, 401, 403, 404, 500, 503].map((status) => ({ mode, status }))))( + '$mode does not retry HTTP $status', + async ({ mode, status }) => { + const response = new Response('Refused', { status }); + const cancel = vi.spyOn(response.body!, 'cancel'); + fetchMock.mockResolvedValueOnce(response); + await expect(invoke(mode)).rejects.toMatchObject({ failure: mode === 'search' && [401, 403].includes(status) ? 'unauthorized' : 'error' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + }, + ); + + it.each(MODES)('%s stops waiting immediately on abort and sends no further request', async (mode) => { + vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal); + fetchMock.mockResolvedValueOnce(throttled()); + const outcome = expect(invoke(mode)).rejects.toMatchObject({ failure: 'throttled' }); + await vi.advanceTimersByTimeAsync(0); + controller.abort(new DOMException('Cancelled', 'AbortError')); + await outcome; + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each(MODES)('%s does not fetch when already aborted', async (mode) => { + vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal); + controller.abort(new DOMException('Timed out', 'TimeoutError')); + await expect(invoke(mode)).rejects.toMatchObject({ failure: 'timeout' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the NZB deadline across waits and an in-flight retry', async () => { + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => { + setTimeout(() => controller.abort(new DOMException('Timed out', 'TimeoutError')), ms); + return controller.signal; + }); + fetchMock + .mockResolvedValueOnce(throttled('600')) + .mockResolvedValueOnce(throttled('600')) + .mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => reject(new DOMException('Timed out', 'TimeoutError')), { once: true }), + ), + ); + const outcome = expect(invoke('nzb')).rejects.toBeInstanceOf(IndexerRetryExhaustedException); + await vi.advanceTimersByTimeAsync(24_999); + expect(fetchMock).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1); + await outcome; + expect(Date.now() - NOW.getTime()).toBe(25_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('removes the abort listener after a completed wait', async () => { + const add = vi.spyOn(controller.signal, 'addEventListener'); + const remove = vi.spyOn(controller.signal, 'removeEventListener'); + fetchMock.mockResolvedValueOnce(throttled()).mockResolvedValueOnce(success('search')); + const outcome = expect(invoke('search')).resolves.toEqual(expected('search')); + await vi.advanceTimersByTimeAsync(5000); + await outcome; + expect(remove).toHaveBeenCalledWith('abort', add.mock.calls[0]![1]); + }); + + it('still rejects a private redirect after a rate-limit retry', async () => { + fetchMock + .mockResolvedValueOnce(throttled('0')) + .mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/private' } })); + const outcome = expect(invoke('nzb')).rejects.toMatchObject({ failure: 'error' }); + await vi.runAllTimersAsync(); + await outcome; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not retry a successful HTTP response containing invalid NZB data', async () => { + fetchMock.mockResolvedValueOnce(throttled('0')).mockResolvedValueOnce(new Response('Login')); + const outcome = expect(invoke('nzb')).rejects.toMatchObject({ failure: 'error', message: expect.stringContaining('not a valid NZB') }); + await vi.runAllTimersAsync(); + await outcome; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/src/modules/book-request/indexers/indexer-adapter.ts b/server/src/modules/book-request/indexers/indexer-adapter.ts index b03bcab41..f294dff5a 100644 --- a/server/src/modules/book-request/indexers/indexer-adapter.ts +++ b/server/src/modules/book-request/indexers/indexer-adapter.ts @@ -153,6 +153,9 @@ export class IndexerSearchException extends Error { } } +/** The transport already spent its retry budget, so callers must not start another one. */ +export class IndexerRetryExhaustedException extends IndexerSearchException {} + /** One file, resolved from a release that is served over plain HTTP rather than as a torrent. */ export interface ReleaseFile { url: string;