// The error taxonomy. The whole point of this file is one column of one table: // **exactly one error class moves the cursor**, and its action is a full // verified rebuild. Everywhere else, failure means the cursor stands still. // // That is what makes "a failure never causes silent data loss" structural rather // than aspirational - and it is precisely what the mobile client's shipped // defect D4 got wrong, by collapsing every error to `null` and then adopting a // snapshot state as the next cursor. A transient 503 on `Email/changes` was // enough to fast-forward the cursor over every change the client had not seen. export type ErrorClass = | 'Transport' | 'RateLimit' | 'ServerTransient' | 'RequestLimit' | 'Auth' | 'Fatal' | 'StateInvalid'; /** True for the one class that moves a cursor - and it moves it to "invalidated". */ export function movesCursor(cls: ErrorClass): boolean { return cls === 'StateInvalid'; } /** * Only a size/availability problem is worth escalating to a rebuild. * * Escalating on RateLimit would mean the response to a rate-limited server is to * issue far MORE requests - a full window re-enumeration. Escalating on Auth * would let a 401 trigger a rebuild; on Transport, a flaky tunnel would do the * same. Fatal is our own bug and a rebuild will not fix it. */ export function escalationApplies(cls: ErrorClass): boolean { return cls === 'ServerTransient' || cls === 'RequestLimit'; } /** JMAP method-level error types that invalidate a `/changes` cursor. */const STATE_INVALID_TYPES = new Set(['cannotCalculateChanges']); const FATAL_TYPES = new Set([ 'invalidArguments', 'unknownMethod', 'accountNotFound', 'forbidden', 'unsupportedFilter', 'unsupportedSort', 'invalidResultReference', 'accountNotSupportedByMethod', 'accountReadOnly', ]); const REQUEST_LIMIT_TYPES = new Set([ 'maxSizeRequest', 'maxCallsInRequest', 'requestTooLarge', 'maxObjectsInGet', 'tooLarge', ]); const SERVER_TRANSIENT_TYPES = new Set([ 'serverUnavailable', 'serverFail', 'serverPartialFail', 'stateMismatch', ]); export class ReplicaSyncError extends Error { readonly cls: ErrorClass; readonly retryAfterMs?: number; constructor(cls: ErrorClass, message: string, retryAfterMs?: number) { super(message); this.name = 'ReplicaSyncError'; this.cls = cls; this.retryAfterMs = retryAfterMs; } } /** * Classification is STRUCTURE BEFORE STRINGS: HTTP status, then JMAP error type, * and only then message prose. A method error's `description` can legitimately * contain the words "timeout" or "socket", and `fetch failed: ECONNRESET` must * not be read as a JMAP method error. */ export function classify(input: { httpStatus?: number; jmapErrorType?: string; message?: string; }): ErrorClass { const { httpStatus, jmapErrorType, message } = input; if (typeof httpStatus === 'number') { if (httpStatus === 401 || httpStatus === 403) return 'Auth'; if (httpStatus === 429) return 'RateLimit'; if (httpStatus === 413) return 'RequestLimit'; if (httpStatus >= 500) return 'ServerTransient'; } if (jmapErrorType) { if (STATE_INVALID_TYPES.has(jmapErrorType)) return 'StateInvalid'; if (REQUEST_LIMIT_TYPES.has(jmapErrorType)) return 'RequestLimit'; if (FATAL_TYPES.has(jmapErrorType)) return 'Fatal'; if (SERVER_TRANSIENT_TYPES.has(jmapErrorType)) return 'ServerTransient'; if (jmapErrorType === 'limit') return 'RateLimit'; // An UNRECOGNISED method-level error is ServerTransient, never Fatal and // never StateInvalid. Guessing transient costs a retry; guessing // state-invalid costs a full resync; guessing fatal stalls the account. The // cheapest wrong answer wins the default. return 'ServerTransient'; } if (message) { const lower = message.toLowerCase(); if ( lower.includes('fetch failed') || lower.includes('econnrefused') || lower.includes('econnreset') || lower.includes('enotfound') || lower.includes('etimedout') || lower.includes('socket') || lower.includes('network') || lower.includes('timed out') || lower.includes('eai_again') || lower.includes('ehostunreach') || lower.includes('enetunreach') || lower.includes('certificate') ) { // "Offline is not an error." Transport failures leave every cursor exactly // where it was and are retried later. return 'Transport'; } } return 'ServerTransient'; } /** Full-jitter exponential backoff. */ export function backoffDelayMs(attempt: number, opts: { baseMs?: number; capMs?: number } = {}): number { const base = opts.baseMs ?? 1_000; const cap = opts.capMs ?? 60_000; const ceiling = Math.min(cap, base * 2 ** Math.max(0, attempt)); // Jitter is not decoration: several triggers fire at once (launch catch-up, // network recovery, a push burst) against one Stalwart instance, which is // exactly the shape that produces a synchronised stampede. return Math.floor(Math.random() * ceiling); } /** * The `maxChanges` ladder, monotonically SHRINKING, every rung expressed * relative to rung 0. * * Two bugs live here historically. First, an unbounded middle rung produced a * retry strictly LARGER than the attempt that just failed - actively worsening a * "response too large" error. Then clamping only rung 0 reintroduced it in a * narrower form: a server advertising `maxObjectsInGet: 100` gave rung 0 = 100 * and rung 1 = 250. Deriving every rung from rung 0 is what makes * monotonic-non-increase true for every server value. */ export function rungValue(rung: 0 | 1 | 2 | 3, maxObjectsInGet: number | undefined): number { const rung0 = Math.max(1, Math.min(maxObjectsInGet ?? 500, 500)); switch (rung) { case 0: return rung0; case 1: return Math.max(1, Math.min(rung0, 250)); case 2: return Math.max(1, Math.min(rung0, 50)); case 3: return Math.max(1, Math.min(rung0, 25)); } } export function nextRung(rung: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 { return rung >= 3 ? 3 : ((rung + 1) as 0 | 1 | 2 | 3); }