diff --git a/src/core/infrastructure/adapters/split/PDFSplitter.ts b/src/core/infrastructure/adapters/split/PDFSplitter.ts index 39f2e79..d8f4497 100644 --- a/src/core/infrastructure/adapters/split/PDFSplitter.ts +++ b/src/core/infrastructure/adapters/split/PDFSplitter.ts @@ -12,6 +12,16 @@ import { WORKER_CONFIG } from '../../config/worker.config.js'; /** A rendered page, held in memory until it is written to the split directory. */ type RenderedPage = Omit & { content: Buffer }; +/** PDF errors that a retry cannot fix: the document itself is protected or invalid. */ +function isRetryablePdfError(error: Error): boolean { + const message = error.message.toLowerCase(); + return !( + message.includes('password') || + message.includes('encrypted') || + message.includes('invalid pdf') + ); +} + /** * PDF splitter implementation using pdf-to-png-converter. * @@ -161,6 +171,7 @@ export class PDFSplitter implements ISplitter { const taskDir = ImagePathUtil.getTaskDir(taskId); try { + // Retry whatever the error says: its message embeds the output path, which can hold any word. return await this.withRetry(async () => { await fs.mkdir(taskDir, { recursive: true }); @@ -171,7 +182,7 @@ export class PDFSplitter implements ISplitter { pages.push({ page, pageSource, imagePath }); } return pages; - }, 'write page images'); + }, 'write page images', () => true); } catch (error) { throw new Error(`Failed to write split images to ${taskDir}: ${(error as Error).message}`); } @@ -182,11 +193,13 @@ export class PDFSplitter implements ISplitter { * * @param operation - Async operation to retry * @param operationName - Name for logging + * @param isRetryable - Whether a failure is worth another attempt (default: PDF errors) * @returns Operation result */ private async withRetry( operation: () => Promise, - operationName: string + operationName: string, + isRetryable: (error: Error) => boolean = isRetryablePdfError ): Promise { const maxRetries = WORKER_CONFIG.splitter.maxRetries; const baseDelay = WORKER_CONFIG.splitter.retryDelayBase; @@ -199,14 +212,7 @@ export class PDFSplitter implements ISplitter { } catch (error) { lastError = error as Error; - // Check for non-retryable errors - const errorMessage = lastError.message.toLowerCase(); - if ( - errorMessage.includes('password') || - errorMessage.includes('encrypted') || - errorMessage.includes('invalid pdf') - ) { - // Don't retry for these errors + if (!isRetryable(lastError)) { throw lastError; } diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts index c0b1aa0..71955b5 100644 --- a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts @@ -272,6 +272,32 @@ describe('PDFSplitter', () => { expect(pdfToPng).toHaveBeenCalledTimes(1); }); + it.each(['password', 'encrypted', 'invalid pdf'])( + 'should retry a failed write when the output path contains "%s"', + async (word) => { + const target = path.join('/data', word, 'page-1.png'); + vi.mocked(fs.writeFile).mockRejectedValueOnce(systemError('EBUSY', 'open', target)); + + const result = await splitter.split(task); + + expect(result.totalPages).toBe(1); + expect(fs.writeFile).toHaveBeenCalledTimes(2); + } + ); + + it('should fail without writing any page when one has no image', async () => { + vi.mocked(pdfToPng).mockResolvedValue([ + { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, + { path: '', name: 'temp-2.png', pageNumber: 2 }, + ] as any); + + const failure = splitter.split(task); + + await expect(failure).rejects.toThrow('Failed to split PDF document.pdf'); + await expect(failure).rejects.toThrow('PDF conversion produced no image for page 2'); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + it('should not blame the source when writing a page keeps failing, even if it is ENOENT', async () => { const target = path.join(splitDir, 'page-1.png'); vi.mocked(fs.writeFile).mockRejectedValue(systemError('ENOENT', 'open', target));