From 594907306ed10ce0c84b0f4e6ed1f0e8fb7e0abf Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:38:34 +0000 Subject: [PATCH] fix(server): retry rate-limited newznab requests Honor Retry-After for Newznab searches and NZB downloads so transient rate limits do not immediately end automatic fulfillment. Bound retries and preserve deadlines without restarting the outer retry budget. Add unit coverage for rate-limit headers, exhaustion, cancellation, redirects, and suppression of nested retries. Verify automatic grab recovery with a real HTTP reproduction. Fixes #1749 --- .../request-fulfillment.service.test.ts | 20 +- .../request-fulfillment.service.ts | 10 +- .../indexers/adapters/newznab.adapter.ts | 90 +++++- .../adapters/newznab.rate-limit.test.ts | 281 ++++++++++++++++++ .../book-request/indexers/indexer-adapter.ts | 3 + 5 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 server/src/modules/book-request/indexers/adapters/newznab.rate-limit.test.ts 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;