fix(anilist): dedupe failures during retry cooldown and block dead-lette

- Ignore markFailure calls while an item is still within its retry backoff window
- Prevent enqueue from re-adding keys already in the dead-letter queue
This commit is contained in:
2026-05-26 01:57:25 -07:00
parent f62fff2585
commit 2add95d541
3 changed files with 56 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- Prevent repeated missing-token checks from rapidly exhausting AniList retry attempts or duplicating dead-letter entries for the same episode.
@@ -71,7 +71,7 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
assert.equal((pendingPayload.pending[0]?.nextAttemptAt ?? now) - now, 30_000);
for (let attempt = 2; attempt <= 8; attempt += 1) {
queue.markFailure('k2', `fail-${attempt}`, now);
queue.markFailure('k2', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
}
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
@@ -83,6 +83,52 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
);
});
test('anilist update queue ignores duplicate failures while retry is cooling down', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
const now = 1_700_000 * 1_000_000;
queue.enqueue('k2', 'Backoff Demo', 2);
queue.markFailure('k2', 'fail-1', now);
for (let attempt = 2; attempt <= 12; attempt += 1) {
queue.markFailure('k2', `duplicate-${attempt}`, now + attempt);
}
const payload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as {
pending: Array<{ attemptCount: number; lastError: string | null }>;
deadLetter: Array<unknown>;
};
assert.equal(payload.pending[0]?.attemptCount, 1);
assert.equal(payload.pending[0]?.lastError, 'fail-1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
pending: 1,
ready: 1,
deadLetter: 0,
});
});
test('anilist update queue does not re-enqueue dead-lettered keys', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
const now = 1_700_000 * 1_000_000;
queue.enqueue('k4', 'Dead Letter Demo', 4);
for (let attempt = 1; attempt <= 8; attempt += 1) {
queue.markFailure('k4', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
}
queue.enqueue('k4', 'Dead Letter Demo', 4);
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
pending: 0,
ready: 0,
deadLetter: 1,
});
});
test('anilist update queue persists and reloads from disk', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
@@ -108,7 +108,8 @@ export function createAnilistUpdateQueue(
return {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
const existing = pending.find((item) => item.key === key);
const existing =
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
if (existing) {
return;
}
@@ -147,6 +148,9 @@ export function createAnilistUpdateQueue(
if (!item) {
return;
}
if (item.attemptCount > 0 && item.nextAttemptAt > nowMs) {
return;
}
item.attemptCount += 1;
item.lastError = reason;
if (item.attemptCount >= MAX_ATTEMPTS) {