mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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
This commit is contained in:
@@ -4,7 +4,7 @@ import { NotificationType, UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES, WORKER_WRIT
|
|||||||
import type { RequestUser } from '../../../common/types/request-user';
|
import type { RequestUser } from '../../../common/types/request-user';
|
||||||
import type { BookRequestDownloadRow, BookRequestRow } from '../../../db/schema';
|
import type { BookRequestDownloadRow, BookRequestRow } from '../../../db/schema';
|
||||||
import { newznabClientKey } from '../indexers/adapters/newznab.adapter';
|
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';
|
import { RequestFulfillmentService } from './request-fulfillment.service';
|
||||||
|
|
||||||
const INFO_HASH = 'c9e15763f722f23e98a29decdfae341b98d53056';
|
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 () => {
|
it('inspects the torrent without creating an attempt and reuses it for the following grab', async () => {
|
||||||
const { service, downloads, adapter, indexerAdapter } = makeService({
|
const { service, downloads, adapter, indexerAdapter } = makeService({
|
||||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { DownloadClientRegistry } from '../download-clients/download-client-regi
|
|||||||
import { ADD_PATH_MAPPING_HINT } from '../download-clients/path-mapping.service';
|
import { ADD_PATH_MAPPING_HINT } from '../download-clients/path-mapping.service';
|
||||||
import {
|
import {
|
||||||
IndexerSearchException,
|
IndexerSearchException,
|
||||||
|
IndexerRetryExhaustedException,
|
||||||
type IndexerAdapter,
|
type IndexerAdapter,
|
||||||
type ReleaseCandidate,
|
type ReleaseCandidate,
|
||||||
type ReleaseFile,
|
type ReleaseFile,
|
||||||
@@ -345,7 +346,9 @@ export class RequestFulfillmentService {
|
|||||||
try {
|
try {
|
||||||
return await run();
|
return await run();
|
||||||
} catch (error) {
|
} 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');
|
const code = grabFailureCode(error) ?? (phase === 'client' ? 'GRAB_CLIENT_UNAVAILABLE' : 'GRAB_SOURCE_UNAVAILABLE');
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -857,7 +860,10 @@ async function resolveReleaseFile(adapter: IndexerAdapter, release: ReleaseCandi
|
|||||||
*/
|
*/
|
||||||
function indexerRefusal(error: IndexerSearchException, release: ReleaseCandidate): HttpException {
|
function indexerRefusal(error: IndexerSearchException, release: ReleaseCandidate): HttpException {
|
||||||
if (RETRYABLE_INDEXER_FAILURES.includes(error.failure)) {
|
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);
|
return grabError(release.vipOnly === true ? 'GRAB_VIP_REQUIRED' : 'GRAB_SOURCE_REFUSED', error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ensureSafeUrl, PrivateAddressException } from '../../../../common/utils
|
|||||||
import { buildSearchText } from '../search-text';
|
import { buildSearchText } from '../search-text';
|
||||||
import {
|
import {
|
||||||
IndexerSearchException,
|
IndexerSearchException,
|
||||||
|
IndexerRetryExhaustedException,
|
||||||
type IndexerAdapter,
|
type IndexerAdapter,
|
||||||
type ReleaseCandidate,
|
type ReleaseCandidate,
|
||||||
type ReleaseQuery,
|
type ReleaseQuery,
|
||||||
@@ -23,6 +24,9 @@ const MAX_FEED_ITEMS = 500;
|
|||||||
const MAX_FEED_FIELD_CHARS = MAX_INDEXER_RELEASE_GUID_LENGTH;
|
const MAX_FEED_FIELD_CHARS = MAX_INDEXER_RELEASE_GUID_LENGTH;
|
||||||
const REQUEST_TIMEOUT_MS = 25_000;
|
const REQUEST_TIMEOUT_MS = 25_000;
|
||||||
const MAX_REDIRECTS = 5;
|
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 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';
|
const NZB_ACCEPT = 'application/x-nzb, application/xml, text/xml, */*;q=0.1';
|
||||||
|
|
||||||
@@ -88,12 +92,9 @@ export class NewznabAdapter implements IndexerAdapter {
|
|||||||
try {
|
try {
|
||||||
let current = await ensureSafeUrl(release.downloadUrl, { allowPrivate: config.allowPrivateAddress });
|
let current = await ensureSafeUrl(release.downloadUrl, { allowPrivate: config.allowPrivateAddress });
|
||||||
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
||||||
|
const retries = { remaining: MAX_RATE_LIMIT_RETRIES };
|
||||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||||
const response = await safeFetch(
|
const response = await fetchWithRateLimitRetry(current.href, config, NZB_ACCEPT, signal, retries);
|
||||||
current.href,
|
|
||||||
{ signal, redirect: 'manual', headers: { Accept: NZB_ACCEPT } },
|
|
||||||
{ allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status >= 300 && response.status < 400) {
|
if (response.status >= 300 && response.status < 400) {
|
||||||
const location = response.headers.get('location');
|
const location = response.headers.get('location');
|
||||||
@@ -103,7 +104,10 @@ export class NewznabAdapter implements IndexerAdapter {
|
|||||||
continue;
|
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');
|
const body = await readBounded(response, MAX_NZB_FILE_BYTES, 'The indexer returned an NZB that is too large');
|
||||||
assertNzb(body);
|
assertNzb(body);
|
||||||
return body;
|
return body;
|
||||||
@@ -127,11 +131,13 @@ export class NewznabAdapter implements IndexerAdapter {
|
|||||||
const options = { allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true };
|
const options = { allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true };
|
||||||
let current = await endpointUrl(config, params);
|
let current = await endpointUrl(config, params);
|
||||||
let response: Response | null = null;
|
let response: Response | null = null;
|
||||||
|
const retries = { remaining: MAX_RATE_LIMIT_RETRIES };
|
||||||
|
|
||||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
|
if (error instanceof IndexerSearchException) throw error;
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
||||||
throw new IndexerSearchException('timeout', `${config.name} did not answer in time`);
|
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) 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.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}`);
|
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<Response> {
|
||||||
|
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<void> {
|
||||||
|
signal.throwIfAborted();
|
||||||
|
await new Promise<void>((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 {
|
export function newznabClientKey(indexerId: number, guid: string): string {
|
||||||
return createHash('sha256').update(String(indexerId)).update('\0').update(guid).digest('hex');
|
return createHash('sha256').update(String(indexerId)).update('\0').update(guid).digest('hex');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof import('node:dns/promises')>()),
|
||||||
|
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('<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"><file /></nzb>');
|
||||||
|
const FEED =
|
||||||
|
'<rss><channel><item><title>Dune EPUB</title><guid>release-1</guid><link>https://indexer.example.test/get/1</link></item></channel></rss>';
|
||||||
|
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('<html>Login</html>'));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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. */
|
/** One file, resolved from a release that is served over plain HTTP rather than as a torrent. */
|
||||||
export interface ReleaseFile {
|
export interface ReleaseFile {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user