UNPKG

@moonwell-fi/moonwell-sdk

Version:

TypeScript Interface for Moonwell

122 lines (111 loc) 3.92 kB
/** * Retry helpers for SDK API calls. * * Two consumers: * 1. `attachRetryInterceptor` — axios response interceptor used inside * LunarIndexerClient. One install per axios instance covers every method. * 2. `retry` — generic wrapper for ad-hoc `axios.post(...)` callsites in * actions that don't go through a shared axios instance. * * Policy: * - 3 attempts (initial + 2 retries) by default * - Exponential backoff: 250ms → 500ms (capped at 5s) * - Retry only network errors / timeouts / 5xx; never retry 4xx (incl. 404 — * retrying a deterministic "not found" wastes time and amplifies log noise) * - After retries exhaust, the last error propagates to the caller's try/catch * where the existing `environment.onError(...)` wiring picks it up */ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, } from "axios"; export interface RetryOptions { maxAttempts?: number; initialDelay?: number; maxDelay?: number; } const DEFAULT_MAX_ATTEMPTS = 3; const DEFAULT_INITIAL_DELAY_MS = 250; const DEFAULT_MAX_DELAY_MS = 5_000; async function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } function backoffDelay( attemptIndex: number, initialDelay: number, maxDelay: number, ): number { return Math.min(initialDelay * 2 ** attemptIndex, maxDelay); } /** * Decide whether an error represents a transient failure worth retrying. * - Axios network errors (no `response`) → retry (server unreachable, timeout) * - 5xx responses → retry (server hiccup) * - 4xx responses (incl. 404) → do NOT retry (deterministic, won't change) * - Non-axios errors (parse errors, code bugs) → do NOT retry */ export function isRetriableError(error: unknown): boolean { if (!axios.isAxiosError(error)) return false; if (!error.response) return true; return error.response.status >= 500; } /** * Attach a retry-on-failure interceptor to an axios instance. Every request * made through this instance is retried up to `maxAttempts` times when the * failure is retriable. Per-config state is tracked on a WeakMap so we don't * pollute the public axios config shape. */ export function attachRetryInterceptor( instance: AxiosInstance, options: RetryOptions = {}, ): void { const { maxAttempts = DEFAULT_MAX_ATTEMPTS, initialDelay = DEFAULT_INITIAL_DELAY_MS, maxDelay = DEFAULT_MAX_DELAY_MS, } = options; const attemptsByConfig = new WeakMap<InternalAxiosRequestConfig, number>(); instance.interceptors.response.use( (response) => response, async (error: AxiosError) => { const config = error.config; if (!config) throw error; if (!isRetriableError(error)) throw error; const previousAttempts = attemptsByConfig.get(config) ?? 0; if (previousAttempts >= maxAttempts - 1) throw error; attemptsByConfig.set(config, previousAttempts + 1); await sleep(backoffDelay(previousAttempts, initialDelay, maxDelay)); return instance.request(config); }, ); } /** * Generic wrapper for individual axios calls (e.g. raw `axios.post(...)` in * action files that don't share a configured instance). Mirrors the * interceptor's policy: retry transient failures, fail fast on 4xx. */ export async function retry<T>( fn: () => Promise<T>, options: RetryOptions = {}, ): Promise<T> { const { maxAttempts = DEFAULT_MAX_ATTEMPTS, initialDelay = DEFAULT_INITIAL_DELAY_MS, maxDelay = DEFAULT_MAX_DELAY_MS, } = options; let attempt = 0; let lastError: unknown; while (attempt < maxAttempts) { try { return await fn(); } catch (error) { lastError = error; attempt++; if (!isRetriableError(error)) throw error; if (attempt >= maxAttempts) break; await sleep(backoffDelay(attempt - 1, initialDelay, maxDelay)); } } throw lastError; }