import { describe, expect, it } from 'vitest'; import { backoffDelayMs, classify, escalationApplies, movesCursor, nextRung, rungValue, type ErrorClass, } from '../errors'; const ALL: ErrorClass[] = [ 'Transport', 'RateLimit', 'ServerTransient', 'RequestLimit', 'Auth', 'Fatal', 'StateInvalid', ]; describe('exactly one class moves a cursor', () => { it('is StateInvalid, and nothing else', () => { // This is the single load-bearing property of the taxonomy. Every other class // leaves the cursor exactly where it was, which is what makes "a failure never // causes silent data loss" structural rather than aspirational. expect(ALL.filter(movesCursor)).toEqual(['StateInvalid']); }); it('escalates to a rebuild only for size/availability problems', () => { // Escalating on RateLimit would answer a rate-limited server with far MORE // requests. On Auth, a 401 would trigger a rebuild. On Transport, a flaky // tunnel would. Fatal is our own bug and a rebuild will not fix it. expect(ALL.filter(escalationApplies).sort()).toEqual(['RequestLimit', 'ServerTransient']); }); }); describe('classify', () => { it('reads HTTP status before anything else', () => { expect(classify({ httpStatus: 401 })).toBe('Auth'); expect(classify({ httpStatus: 403 })).toBe('Auth'); expect(classify({ httpStatus: 429 })).toBe('RateLimit'); expect(classify({ httpStatus: 413 })).toBe('RequestLimit'); expect(classify({ httpStatus: 503 })).toBe('ServerTransient'); }); it('classifies cannotCalculateChanges as the one cursor-moving class', () => { expect(classify({ jmapErrorType: 'cannotCalculateChanges' })).toBe('StateInvalid'); }); it('defaults an UNRECOGNISED method error to ServerTransient', () => { // Guessing transient costs a retry; guessing state-invalid costs a full // resync; guessing fatal stalls the account. The cheapest wrong answer wins. expect(classify({ jmapErrorType: 'somethingNobodyHasHeardOf' })).toBe('ServerTransient'); }); it('does not let a method error description masquerade as a transport failure', () => { // Structure before strings: a method error's prose can legitimately contain // "timeout" or "socket", and reading that as Transport would leave a genuine // server-side problem being retried as though the network were down. expect(classify({ jmapErrorType: 'invalidArguments', message: 'socket timeout' })).toBe('Fatal'); }); it('classifies a real fetch rejection as Transport', () => { // "Offline is not an error": the cursor stands still and the work is retried. for (const message of [ 'fetch failed', 'connect ECONNREFUSED 127.0.0.1:1', 'getaddrinfo ENOTFOUND nope', 'socket hang up', 'The operation timed out', ]) { expect(classify({ message }), message).toBe('Transport'); } }); }); describe('the maxChanges ladder is monotonically non-increasing for EVERY server value', () => { it('never proposes a retry larger than the attempt that just failed', () => { // Two historical bugs live here. An unbounded middle rung produced a retry // STRICTLY LARGER than the failing attempt, actively worsening a // "response too large" error. Clamping only rung 0 then reintroduced it in a // narrower form: maxObjectsInGet=100 gave rung0=100 and rung1=250. const serverValues = [ undefined, 1, 5, 10, 20, 25, 26, 49, 50, 51, 99, 100, 249, 250, 251, 499, 500, 501, 5000, ]; for (const value of serverValues) { const rungs = ([0, 1, 2, 3] as const).map((r) => rungValue(r, value)); for (let i = 1; i < rungs.length; i++) { expect( rungs[i], `maxObjectsInGet=${value} rung ${i} (${rungs[i]}) must not exceed rung ${i - 1} (${rungs[i - 1]})`, ).toBeLessThanOrEqual(rungs[i - 1]); } // And never zero, or the request asks for nothing and never progresses. for (const r of rungs) expect(r).toBeGreaterThanOrEqual(1); } }); it('clamps rung 0 to what the server allows', () => { expect(rungValue(0, 100)).toBe(100); expect(rungValue(0, 5000)).toBe(500); expect(rungValue(0, undefined)).toBe(500); }); it('saturates rather than running off the end of the ladder', () => { expect(nextRung(0)).toBe(1); expect(nextRung(3)).toBe(3); }); }); describe('backoff', () => { it('is full-jitter and bounded by the cap', () => { for (let attempt = 0; attempt < 12; attempt++) { const delay = backoffDelayMs(attempt, { baseMs: 1000, capMs: 60_000 }); expect(delay).toBeGreaterThanOrEqual(0); expect(delay).toBeLessThanOrEqual(60_000); } }); });