fix(server): keep tracker timeouts recoverable

Keep downloads active after transient tracker failures so later completions
reach the importer. Reserve early failure for explicit rejections and import
completed content even when the tracker still reports an old rejection.

Cover timeout recovery, transient failures, and explicit rejections with unit
tests.

Closes #1607
This commit is contained in:
neon
2026-10-11 07:02:19 +00:00
parent 4770032ce7
commit 4440fea99a
3 changed files with 117 additions and 10 deletions
@@ -67,9 +67,9 @@ export interface DownloadStatus {
seed?: SeedInfo;
errorMessage?: string;
/**
* The tracker's own refusal, where every tracker on a torrent that is getting nowhere reports
* one. A private tracker rejecting the announce leaves the torrent in a perfectly healthy-looking
* stalled state, so without this the only symptom is a download that never starts.
* The tracker's error, which may be a transient transport failure or an explicit rejection.
* A failed announce can leave the torrent in a healthy-looking stalled state; the monitor
* classifies the message before deciding whether the attempt is over.
*/
trackerError?: string;
}
@@ -521,6 +521,99 @@ describe('DownloadMonitorService.tick', () => {
expect(fulfillment.failDownload).toHaveBeenCalledWith(expect.objectContaining({ id: 11 }), expect.stringContaining(REFUSED));
});
it.each([
'timed out',
'Connection timed out',
'Tracker gave HTTP response code 503',
'Tracker gave HTTP response code 429',
'Could not resolve host',
'Connection refused',
'Network is unreachable',
'An unknown tracker error',
])('keeps polling a stalled download after a transient or unclassified error: %s', async (trackerError) => {
const { service, fulfillment, downloads } = makeService({
statuses: [status({ downloadedBytes: 0, progressPercent: 0, trackerError })],
});
await polled(service);
expect(fulfillment.failDownload).not.toHaveBeenCalled();
expect(downloads.updateIf).toHaveBeenCalledWith(
11,
ACTIVE_BOOK_REQUEST_DOWNLOAD_STATUSES,
expect.objectContaining({ status: 'downloading', downloadedBytes: 0 }),
);
});
it.each([
'Invalid passkey',
'Unregistered torrent',
'Torrent not found',
'Tracker gave HTTP response code 401',
'Tracker gave HTTP response code 403',
'HTTP 403 Forbidden',
'Access denied',
'Denied',
])('still fails an explicit tracker rejection after the grace period: %s', async (trackerError) => {
const { service, fulfillment } = makeService({
statuses: [status({ downloadedBytes: 0, progressPercent: 0, trackerError })],
});
await polled(service);
expect(fulfillment.failDownload).toHaveBeenCalledWith(expect.objectContaining({ id: 11 }), expect.stringContaining(trackerError));
});
it('imports a torrent that starts and completes after a tracker timeout beyond the grace period', async () => {
const { service, fulfillment, downloads, adapter, imports } = makeService({
active: [row({ downloadedBytes: 0, progressPercent: 0 })],
});
downloads.findActive.mockImplementation(async () => {
const latest = await downloads.findById(11);
return latest && ACTIVE_BOOK_REQUEST_DOWNLOAD_STATUSES.includes(latest.status) ? [latest] : [];
});
fulfillment.failDownload.mockImplementation(async () => {
await downloads.update(11, { status: 'failed' });
});
adapter.status
.mockResolvedValueOnce([status({ downloadedBytes: 0, progressPercent: 0, trackerError: 'timed out' })])
.mockResolvedValueOnce([status({ downloadedBytes: 500, progressPercent: 50 })])
.mockResolvedValueOnce([status({ state: 'completed', downloadedBytes: 1000, progressPercent: 100 })]);
const now = vi.spyOn(Date, 'now');
const startedAt = new Date().getTime();
try {
now.mockReturnValue(startedAt);
await polled(service);
now.mockReturnValue(startedAt + 5_000);
await polled(service);
now.mockReturnValue(startedAt + 10_000);
await polled(service);
await importQueue(service).waitForIdle();
expect(fulfillment.failDownload).not.toHaveBeenCalled();
expect(adapter.status).toHaveBeenCalledTimes(3);
expect(imports.importDownload).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: 11, status: 'completed', progressPercent: 100, downloadedBytes: 1000 }),
);
} finally {
now.mockRestore();
service.onModuleDestroy();
}
});
it('imports completed content despite a stale tracker rejection and reset byte counters', async () => {
const { service, fulfillment, imports } = makeService({
statuses: [status({ state: 'completed', downloadedBytes: 0, progressPercent: 100, trackerError: REFUSED })],
});
await polled(service);
await importQueue(service).waitForIdle();
expect(fulfillment.failDownload).not.toHaveBeenCalled();
expect(imports.importDownload).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ id: 11, status: 'completed' }));
});
/** A tracker restart looks identical for the first few seconds, and costs nobody a download. */
it('gives a freshly grabbed torrent a grace window first', async () => {
const now = new Date();
@@ -24,9 +24,8 @@ import { RequestImportService } from './request-import.service';
const MISSING_CLIENT_ITEM_GRACE_MS = 2 * 60 * 1000;
/**
* A tracker that is briefly down is indistinguishable from one that is refusing us, and only the
* second is worth failing a request over. Short enough to beat the watchdog's twelve hours by a
* wide margin, long enough that a tracker restart does not cost anybody their download.
* Give a newly grabbed torrent time to announce before acting on an explicit tracker rejection.
* Transport failures and unknown tracker errors stay recoverable until the stalled watchdog.
*/
const TRACKER_ERROR_GRACE_MS = 5 * 60 * 1000;
const DIRECT_PROGRESS_INTERVAL_MS = 1_000;
@@ -286,10 +285,15 @@ export class DownloadMonitorService implements OnModuleDestroy {
return;
}
// A refused announce is not a client-level error, so the torrent sits in an ordinary stalled
// state that reads as a healthy download. Left alone it would occupy the queue until the
// watchdog gave up on it half a day later, with nothing on the request saying why.
if (status.trackerError && status.downloadedBytes === 0 && this.olderThan(row, TRACKER_ERROR_GRACE_MS)) {
// A tracker timeout does not end the torrent in the client. Failing it here would remove it
// from polling and miss a later completion, so only an explicit rejection ends the attempt.
if (
status.state !== 'completed' &&
status.trackerError &&
isTrackerRejection(status.trackerError) &&
status.downloadedBytes === 0 &&
this.olderThan(row, TRACKER_ERROR_GRACE_MS)
) {
await this.fulfillment.failDownload(row, `The tracker rejected this download: ${status.trackerError}`);
return;
}
@@ -348,3 +352,13 @@ export class DownloadMonitorService implements OnModuleDestroy {
function pollTargetFor(row: BookRequestDownloadRow): PollTarget | null {
return row.source === 'direct_url' ? DIRECT : row.downloadClientId;
}
/** Unknown messages, including localized ones, must not turn a recoverable outage into a failure. */
function isTrackerRejection(message: string): boolean {
return (
/\b(?:unrecognized|invalid|incorrect|unknown|missing|bad)\s+(?:host\/)?pass\s*key\b/i.test(message) ||
/\b(?:unregistered|unknown|invalid)\s+torrent\b|\btorrent\s+(?:not found|not registered|unregistered)\b/i.test(message) ||
/\b(?:unauthori[sz]ed|forbidden|access denied)\b|^\s*denied[.!]?\s*$/i.test(message) ||
/\bHTTP(?:\s+(?:response(?:\s+code)?|status(?:\s+code)?|error|code))?\s*[:=]?\s*(?:401|403)\b/i.test(message)
);
}