fix: try next release after transient grab failure (#275)

This commit is contained in:
kikootwo
2026-08-18 16:09:36 -04:00
committed by GitHub
parent a013e2191f
commit 425129e8c9
17 changed files with 455 additions and 105 deletions
@@ -6,6 +6,7 @@
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { DownloadSourceError } from '@/lib/interfaces/download-client.interface';
const clientMock = vi.hoisted(() => ({
get: vi.fn(),
@@ -693,6 +694,32 @@ describe('QBittorrentService', () => {
);
});
it('preserves upstream HTTP status and forwards source headers', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=source-error';
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
axiosMock.get.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, headers: {} },
message: 'Request failed',
});
const error = await service.addDownload(
'https://prowlarr/1/download/limited',
{ sourceHeaders: { 'X-Api-Key': 'secret' } }
).catch((caught) => caught);
expect(error).toBeInstanceOf(DownloadSourceError);
expect(error).toMatchObject({ status: 500 });
expect(axiosMock.get).toHaveBeenCalledWith(
'https://prowlarr/1/download/limited',
expect.objectContaining({
headers: expect.objectContaining({ 'X-Api-Key': 'secret' }),
})
);
});
it('throws for invalid redirect locations when fetching torrents', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
@@ -6,6 +6,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
import { createJobQueueMock } from '../helpers/job-queue';
import { DownloadSourceError } from '@/lib/interfaces/download-client.interface';
const prismaMock = createPrismaMock();
const configMock = vi.hoisted(() => ({
@@ -214,4 +215,107 @@ describe('processDownloadTorrent', () => {
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet');
});
it('tries the next ranked release when an indexer grab returns 500', async () => {
const fallbackTorrent = {
...torrentPayload.torrent,
indexer: 'Healthy Indexer',
indexerId: 2,
title: 'Book - Author - Fallback',
downloadUrl: 'https://prowlarr/2/download/fallback',
guid: 'guid-fallback',
};
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
addDownload: vi.fn()
.mockRejectedValueOnce(new DownloadSourceError(
'Grab failed: source returned HTTP 500',
500,
'https://prowlarr/1/download/limited'
))
.mockResolvedValueOnce('fallback-hash'),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
enabled: true,
category: 'readmeabook',
});
prismaMock.request.update.mockResolvedValue({
type: 'audiobook',
user: { plexUsername: 'testuser' },
});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-fallback' });
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
const result = await processDownloadTorrent({
...torrentPayload,
torrent: {
...torrentPayload.torrent,
downloadUrl: 'https://prowlarr/1/download/limited',
},
alternateTorrents: [fallbackTorrent],
});
expect(result.success).toBe(true);
expect(result.torrent.title).toBe(fallbackTorrent.title);
expect(qbtClientMock.addDownload).toHaveBeenNthCalledWith(
1,
'https://prowlarr/1/download/limited',
expect.anything()
);
expect(qbtClientMock.addDownload).toHaveBeenNthCalledWith(
2,
fallbackTorrent.downloadUrl,
expect.anything()
);
expect(prismaMock.downloadHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
indexerName: 'Healthy Indexer',
torrentName: fallbackTorrent.title,
downloadClientId: 'fallback-hash',
}),
})
);
});
it('leaves the request retryable when every ranked grab is temporarily unavailable', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
addDownload: vi.fn().mockRejectedValue(new DownloadSourceError(
'Grab failed: source returned HTTP 429',
429,
'https://prowlarr/1/download/limited'
)),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
enabled: true,
category: 'readmeabook',
});
prismaMock.request.update.mockResolvedValue({
type: 'audiobook',
user: { plexUsername: 'testuser' },
});
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
await expect(processDownloadTorrent({
...torrentPayload,
torrent: {
...torrentPayload.torrent,
downloadUrl: 'https://prowlarr/1/download/limited',
},
})).rejects.toThrow('Indexer returned HTTP 429');
expect(prismaMock.request.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) })
);
expect(prismaMock.downloadHistory.create).not.toHaveBeenCalled();
});
});
@@ -88,6 +88,17 @@ describe('processSearchIndexers', () => {
guid: 'guid-1',
format: 'M4B',
},
{
indexer: 'Fallback Indexer',
indexerId: 2,
title: 'Book - Author - Alternate',
size: 50 * 1024 * 1024,
seeders: 1,
publishDate: new Date(),
downloadUrl: 'https://prowlarr/2/download/alternate',
guid: 'guid-2',
format: 'M4B',
},
]);
prismaMock.request.update.mockResolvedValue({});
@@ -103,7 +114,8 @@ describe('processSearchIndexers', () => {
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
'req-2',
{ id: 'a2', title: 'Book', author: 'Author' },
expect.objectContaining({ title: 'Book - Author' })
expect.objectContaining({ title: 'Book - Author' }),
[expect.objectContaining({ title: 'Book - Author - Alternate' })]
);
});
@@ -179,7 +191,8 @@ describe('processSearchIndexers', () => {
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
'req-filter-name',
expect.objectContaining({ id: 'a-filter' }),
expect.objectContaining({ title: 'Good Release - Author' })
expect.objectContaining({ title: 'Good Release - Author' }),
[]
);
});
@@ -235,7 +248,8 @@ describe('processSearchIndexers', () => {
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
'req-filter-hash',
expect.anything(),
expect.objectContaining({ title: 'Good Release - Author' })
expect.objectContaining({ title: 'Good Release - Author' }),
[]
);
});
+11 -2
View File
@@ -202,11 +202,20 @@ describe('JobQueueService', () => {
const { JobQueueService } = await import('@/lib/services/job-queue.service');
const service = new JobQueueService();
await service.addDownloadJob('req-1', { id: 'ab-1', title: 'Title', author: 'Author' }, { hash: 'hash' } as any);
await service.addDownloadJob(
'req-1',
{ id: 'ab-1', title: 'Title', author: 'Author' },
{ hash: 'hash' } as any,
[{ hash: 'fallback-hash' } as any]
);
expect(queueMock.add).toHaveBeenCalledWith(
'download_torrent',
expect.objectContaining({ requestId: 'req-1', jobId: 'job-2' }),
expect.objectContaining({
requestId: 'req-1',
jobId: 'job-2',
alternateTorrents: [{ hash: 'fallback-hash' }],
}),
expect.objectContaining({ priority: 9 })
);
});