All files / scripts/imf/transport retry.ts

100% Statements 19/19
100% Branches 12/12
100% Functions 4/4
100% Lines 15/15

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70                                        20x               20x   20x                               17x 17x 5x 5x 2x         7x 9x   4x 1x         5x 3x   2x    
/**
 * @module imf/transport/retry
 * @description Single retry policy for IMF transport (Datamapper + SDMX).
 *
 * Strategy:
 *  - Base schedule is exponential: 1 s → 2 s → 4 s (attempt 0/1/2).
 *  - When the server supplies a `Retry-After` header (delta-seconds),
 *    honour it, capped at {@link RETRY_AFTER_CAP_MS} to avoid pathological
 *    multi-minute sleeps from a misbehaving origin (matches
 *    THREAT_MODEL.md TB-6a — resource-exhaustion via cooperative back-off).
 *  - Invalid / non-positive `Retry-After` values fall back to the
 *    exponential schedule.
 *
 * @author Hack23 AB
 * @license Apache-2.0
 */
 
import { ImfHttpError } from '../errors/http-error.js';
 
/** Base delay (ms) for the exponential back-off used on 429 / 5xx / network errors. */
const RETRY_BASE_DELAY_MS = 1_000;
 
/**
 * Cap applied to a server-supplied `Retry-After` header so that a
 * misbehaving origin cannot pin the client in a multi-minute sleep.
 * Matches THREAT_MODEL.md TB-6a (resource-exhaustion via cooperative
 * back-off). **MUST remain 30_000** — asserted by unit test.
 */
export const RETRY_AFTER_CAP_MS = 30_000;
 
const NETWORK_TYPE_ERROR_PATTERNS = [
  /fetch failed/i,
  /failed to fetch/i,
  /network/i,
  /load failed/i,
] as const;
 
/**
 * Compute the retry delay (milliseconds) for a given attempt number.
 *
 * Exported to keep the retry math verifiable without spinning up an HTTP stub.
 */
export function calculateRetryDelay(
  attempt: number,
  retryAfterHeader?: string | null,
): number {
  const exponential = RETRY_BASE_DELAY_MS * 2 ** Math.max(0, attempt);
  if (!retryAfterHeader) return exponential;
  const retryAfterSec = Number.parseInt(retryAfterHeader, 10);
  if (!Number.isFinite(retryAfterSec) || retryAfterSec <= 0) return exponential;
  return Math.min(retryAfterSec * 1_000, RETRY_AFTER_CAP_MS);
}
 
/** Whether the error indicates a transient transport failure (network / abort). */
export function isTransientFetchError(error: unknown): boolean {
  if (error instanceof TypeError) {
    return NETWORK_TYPE_ERROR_PATTERNS.some((pattern) => pattern.test(error.message));
  }
  if (error instanceof Error) return error.name === 'AbortError';
  return false;
}
 
/** Whether the error should be retried under the IMF retry policy. */
export function isRetryableError(error: unknown): boolean {
  if (error instanceof ImfHttpError) {
    return error.retryable;
  }
  return isTransientFetchError(error);
}