diff --git a/packages/server/src/utils/federationAuthFailure.test.ts b/packages/server/src/utils/federationAuthFailure.test.ts new file mode 100644 index 00000000..da2591d4 --- /dev/null +++ b/packages/server/src/utils/federationAuthFailure.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { evaluateAuthFailure, AUTH_FAILURE_THRESHOLD } from './federationAuthFailure.js'; + +describe('evaluateAuthFailure', () => { + it('returns backoff with incremented count for a first failure', () => { + const result = evaluateAuthFailure(0); + expect(result).toEqual({ kind: 'backoff', newAuthFailures: 1 }); + }); + + it('returns backoff below the threshold', () => { + for (let prev = 0; prev < AUTH_FAILURE_THRESHOLD - 1; prev++) { + const result = evaluateAuthFailure(prev); + expect(result.kind).toBe('backoff'); + expect(result.newAuthFailures).toBe(prev + 1); + } + }); + + it('returns transition at the threshold', () => { + const result = evaluateAuthFailure(AUTH_FAILURE_THRESHOLD - 1); + expect(result).toEqual({ + kind: 'transition_to_needs_attention', + newAuthFailures: AUTH_FAILURE_THRESHOLD, + }); + }); + + it('returns transition beyond the threshold', () => { + const result = evaluateAuthFailure(AUTH_FAILURE_THRESHOLD + 5); + expect(result.kind).toBe('transition_to_needs_attention'); + expect(result.newAuthFailures).toBe(AUTH_FAILURE_THRESHOLD + 6); + }); + + it('AUTH_FAILURE_THRESHOLD is 5', () => { + expect(AUTH_FAILURE_THRESHOLD).toBe(5); + }); +}); diff --git a/packages/server/src/utils/federationAuthFailure.ts b/packages/server/src/utils/federationAuthFailure.ts new file mode 100644 index 00000000..e37b7723 --- /dev/null +++ b/packages/server/src/utils/federationAuthFailure.ts @@ -0,0 +1,28 @@ +/** + * Number of consecutive 401/403 responses from an active peer before the + * outbox worker transitions that peer to `needs_attention`. + * + * Rationale (see design spec §Retry Budget): with the existing + * BACKOFF_SCHEDULE_MS = [30s, 1m, 5m, 15m, 1h], five consecutive auth + * failures span ~21.5 min — covering the 15-min rotation grace window + * with ~6.5 min margin while still giving clear signal that a persistent + * desync has occurred. + */ +export const AUTH_FAILURE_THRESHOLD = 5; + +export type AuthFailureAction = + | { kind: 'backoff'; newAuthFailures: number } + | { kind: 'transition_to_needs_attention'; newAuthFailures: number }; + +/** + * Pure decision function. Given the current consecutive-auth-failure count, + * return whether the next failure keeps the peer in retry-with-backoff or + * transitions it to the `needs_attention` terminal state. + */ +export function evaluateAuthFailure(currentAuthFailures: number): AuthFailureAction { + const newAuthFailures = currentAuthFailures + 1; + if (newAuthFailures >= AUTH_FAILURE_THRESHOLD) { + return { kind: 'transition_to_needs_attention', newAuthFailures }; + } + return { kind: 'backoff', newAuthFailures }; +}