retry-handler-supplement2.test.js
1 /** 2 * Supplement tests for src/utils/retry-handler.js 3 * 4 * Covers uncovered branches: 5 * - recordFailure: circuit breaker error at max retries → keeps current status, sets retry in 1h 6 * - recordFailure: HTTP 403/404 long-term retry (within limit) 7 * - recordFailure: HTTP 403/404 long-term retry exhausted → marks as failing 8 * - recordFailure: non-circuit, non-http error under retry limit → increments counter 9 * - recordFailure: marks as failing when max retries exceeded (non-special error) 10 * - getRetryStats: returns correct aggregate stats 11 * - resetRetries: zeroes out retry_count and error_message 12 */ 13 14 import { test, describe, mock, beforeEach } from 'node:test'; 15 import assert from 'node:assert/strict'; 16 import Database from 'better-sqlite3'; 17 import { createPgMock } from '../helpers/pg-mock.js'; 18 19 // ── Shared in-memory DB for the db.js mock ──────────────────────────────────── 20 // retry-handler.js imports `run` and `getOne` from db.js. We mock db.js to 21 // route those calls to this in-memory SQLite instance. 22 23 const db = new Database(':memory:'); 24 25 mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) }); 26 27 // ── Import module under test AFTER mock.module ───────────────────────────────── 28 const { recordFailure, resetRetries, getRetryStats } = await import('../../src/utils/retry-handler.js'); 29 30 // ── Schema setup helper ─────────────────────────────────────────────────────── 31 32 function resetSchema() { 33 db.exec(`DROP TABLE IF EXISTS sites`); 34 db.exec(` 35 CREATE TABLE sites ( 36 id INTEGER PRIMARY KEY AUTOINCREMENT, 37 status TEXT NOT NULL DEFAULT 'found', 38 error_message TEXT, 39 retry_count INTEGER DEFAULT 0, 40 recapture_count INTEGER DEFAULT 0, 41 last_retry_at DATETIME, 42 recapture_at DATETIME, 43 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 44 rescored_at DATETIME 45 ) 46 `); 47 } 48 49 function insertSite(overrides = {}) { 50 const { lastInsertRowid } = db 51 .prepare( 52 `INSERT INTO sites (status, retry_count, recapture_count) VALUES (?, ?, ?)` 53 ) 54 .run( 55 overrides.status ?? 'assets_captured', 56 overrides.retry_count ?? 0, 57 overrides.recapture_count ?? 0 58 ); 59 return lastInsertRowid; 60 } 61 62 // ── Tests ───────────────────────────────────────────────────────────────────── 63 64 describe('recordFailure — under retry limit', () => { 65 beforeEach(() => resetSchema()); 66 67 test('increments retry_count and keeps current status', async () => { 68 const id = insertSite({ status: 'assets_captured', retry_count: 0 }); 69 70 const result = await recordFailure(id, 'assets', new Error('ECONNRESET'), 'assets_captured'); 71 72 assert.equal(result, false, 'should return false (still retrying)'); 73 const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id); 74 assert.equal(row.retry_count, 1); 75 assert.equal(row.status, 'assets_captured', 'status should not change while retrying'); 76 assert.ok(row.error_message.includes('ECONNRESET')); 77 }); 78 79 test('handles string error (not Error object)', async () => { 80 const id = insertSite({ status: 'prog_scored', retry_count: 1 }); 81 82 await recordFailure(id, 'scoring', 'Timeout error', 'prog_scored'); 83 const row = db.prepare('SELECT error_message FROM sites WHERE id = ?').get(id); 84 assert.ok(row.error_message.includes('Timeout error')); 85 }); 86 87 test('uses default max retries (5) for unknown stage name', async () => { 88 const id = insertSite({ retry_count: 3 }); // 3 current, max=5 → still retrying 89 90 const result = await recordFailure(id, 'unknown_stage', new Error('Some error'), 'found'); 91 assert.equal(result, false, 'should still be retrying at retry 4/5'); 92 }); 93 }); 94 95 describe('recordFailure — at max retries, non-special errors', () => { 96 beforeEach(() => resetSchema()); 97 98 test('marks site as failing when max retries exceeded for assets stage', async () => { 99 // assets limit = 3, so retry_count=2 means next increment hits 3 which >= 3 → failing 100 const id = insertSite({ status: 'assets_captured', retry_count: 2 }); 101 102 const result = await recordFailure(id, 'assets', new Error('Something broke'), 'assets_captured'); 103 104 assert.equal(result, true, 'should return true (now failing)'); 105 const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id); 106 assert.equal(row.status, 'failing'); 107 assert.ok(row.error_message.includes('Max retries')); 108 assert.ok(row.error_message.includes('Something broke')); 109 }); 110 111 test('marks site as failing for proposals stage at retry limit 5', async () => { 112 const id = insertSite({ retry_count: 4 }); // proposals limit = 5 → hits cap at 5 113 114 const result = await recordFailure(id, 'proposals', new Error('LLM timeout'), 'prog_scored'); 115 assert.equal(result, true, 'should be failing after 5 retries'); 116 const row = db.prepare('SELECT status FROM sites WHERE id = ?').get(id); 117 assert.equal(row.status, 'failing'); 118 }); 119 }); 120 121 describe('recordFailure — circuit breaker error at max retries', () => { 122 beforeEach(() => resetSchema()); 123 124 test('schedules 1-hour retry instead of failing for circuit breaker error', async () => { 125 const id = insertSite({ status: 'prog_scored', retry_count: 2 }); // assets limit=3 → at cap 126 127 const result = await recordFailure( 128 id, 129 'assets', 130 new Error('Breaker is open: too many errors'), 131 'prog_scored' 132 ); 133 134 assert.equal(result, false, 'circuit breaker should NOT mark as failing'); 135 const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id); 136 // Status should remain at current stage 137 assert.equal(row.status, 'prog_scored'); 138 // recapture_at should be set to ~1 hour from now 139 assert.ok(row.recapture_at, 'recapture_at should be set for 1h retry'); 140 assert.ok(row.error_message.includes('Circuit breaker')); 141 }); 142 143 test('recognizes EOPENBREAKER as circuit breaker error', async () => { 144 const id = insertSite({ retry_count: 4 }); // proposals limit=5 → at cap 145 146 const result = await recordFailure( 147 id, 148 'proposals', 149 new Error('EOPENBREAKER: circuit open'), 150 'proposals' 151 ); 152 153 assert.equal(result, false, 'EOPENBREAKER should not mark as failing'); 154 }); 155 }); 156 157 describe('recordFailure — HTTP 403/404 long-term retry', () => { 158 beforeEach(() => resetSchema()); 159 160 test('schedules 7-day retry for HTTP 403 within retry limit', async () => { 161 const id = insertSite({ retry_count: 2, recapture_count: 0 }); // assets limit=3 → at cap 162 163 const result = await recordFailure( 164 id, 165 'assets', 166 new Error('HTTP 403 - Cannot capture assets for error response'), 167 'found' 168 ); 169 170 assert.equal(result, false, 'HTTP 403 within limit should schedule retry, not fail'); 171 const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id); 172 assert.equal(row.status, 'found'); // reset to found for retry 173 assert.equal(row.retry_count, 0); // reset 174 assert.equal(row.recapture_count, 1); 175 assert.ok(row.recapture_at, 'recapture_at should be set for 7-day retry'); 176 }); 177 178 test('schedules 7-day retry for HTTP 404 within retry limit', async () => { 179 const id = insertSite({ retry_count: 2, recapture_count: 3 }); // still within 8 max 180 181 const result = await recordFailure( 182 id, 183 'assets', 184 new Error('HTTP 404 - Cannot capture assets for error response'), 185 'found' 186 ); 187 188 assert.equal(result, false, 'HTTP 404 within long-term limit should schedule retry'); 189 }); 190 191 test('falls through to failing when HTTP retries exhausted (recapture_count >= 8)', async () => { 192 const id = insertSite({ retry_count: 2, recapture_count: 8 }); // at HTTP cap 193 194 const result = await recordFailure( 195 id, 196 'assets', 197 new Error('HTTP 404 - Cannot capture assets for error response'), 198 'found' 199 ); 200 201 assert.equal(result, true, 'should fail after 8 HTTP retries'); 202 const row = db.prepare('SELECT status FROM sites WHERE id = ?').get(id); 203 assert.equal(row.status, 'failing'); 204 }); 205 }); 206 207 describe('resetRetries', () => { 208 beforeEach(() => resetSchema()); 209 210 test('zeroes retry_count, clears error_message and last_retry_at', async () => { 211 const id = insertSite({ retry_count: 3 }); 212 db.prepare( 213 `UPDATE sites SET error_message = 'old error', last_retry_at = CURRENT_TIMESTAMP WHERE id = ?` 214 ).run(id); 215 216 await resetRetries(id); 217 218 const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id); 219 assert.equal(row.retry_count, 0); 220 assert.equal(row.error_message, null); 221 assert.equal(row.last_retry_at, null); 222 }); 223 }); 224 225 describe('getRetryStats', () => { 226 beforeEach(() => resetSchema()); 227 228 test('returns correct counts for failing and retrying sites', async () => { 229 db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('failing', 3)`).run(); 230 db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('failing', 2)`).run(); 231 db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('found', 1)`).run(); 232 db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('assets_captured', 0)`).run(); 233 234 const stats = await getRetryStats(); 235 assert.equal(stats.failing_sites, 2); 236 assert.equal(stats.retrying_sites, 1); // only 'found' with retry_count>0 237 assert.ok(stats.max_retry_count >= 3); 238 }); 239 240 test('returns zero stats for empty table', async () => { 241 const stats = await getRetryStats(); 242 assert.equal(stats.failing_sites, 0); 243 assert.equal(stats.retrying_sites, 0); 244 }); 245 });