retry.ts 2.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. import { setTimeout as sleep } from "node:timers/promises";
  2. const DEFAULT_RETRIES = 3;
  3. const DEFAULT_BASE_DELAY_MS = 800;
  4. const DEFAULT_MAX_DELAY_MS = 8000;
  5. export interface RetryOptions {
  6. /** Retries after the initial attempt (default 3). 0 disables retry. */
  7. retries?: number;
  8. baseDelayMs?: number;
  9. maxDelayMs?: number;
  10. }
  11. /** Read `TTS_MAX_RETRIES` if set, else the provided default. */
  12. function resolveRetries(fallback: number): number {
  13. const raw = process.env.TTS_MAX_RETRIES;
  14. if (raw === undefined || raw === "") return fallback;
  15. const n = Number(raw);
  16. return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
  17. }
  18. /** HTTP statuses worth retrying: rate limiting + server errors. */
  19. export function isRetriableStatus(status: number): boolean {
  20. return status === 429 || (status >= 500 && status <= 599);
  21. }
  22. function delayFor(attempt: number, base: number, max: number): number {
  23. const exp = Math.min(max, base * 2 ** attempt);
  24. return Math.floor(Math.random() * (exp - base + 1)) + base; // full jitter
  25. }
  26. /**
  27. * Drop-in replacement for `fetch` that retries transient failures — network
  28. * errors and HTTP 429/5xx — with exponential backoff + jitter. TTS gateways
  29. * intermittently return 502/503 (upstream DNS, rolling restarts); without
  30. * retry a single blip fails the whole render.
  31. *
  32. * Returns the final `Response` (ok, or a non-retriable error such as 4xx) so
  33. * the caller can read its body. When retries are exhausted on a retriable
  34. * status the last response is still returned (body intact) for diagnostics.
  35. */
  36. export async function fetchWithRetry(
  37. input: string | URL,
  38. init: RequestInit,
  39. opts: RetryOptions = {}
  40. ): Promise<Response> {
  41. const retries = resolveRetries(opts.retries ?? DEFAULT_RETRIES);
  42. const baseDelay = opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
  43. const maxDelay = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
  44. for (let attempt = 0; attempt <= retries; attempt++) {
  45. let response: Response;
  46. try {
  47. response = await fetch(input, init);
  48. } catch (err) {
  49. if (attempt >= retries) throw err;
  50. console.warn(
  51. `[tts] request failed (${err instanceof Error ? err.message : err}), retrying ${attempt + 1}/${retries}`
  52. );
  53. await sleep(delayFor(attempt, baseDelay, maxDelay));
  54. continue;
  55. }
  56. if (response.ok || !isRetriableStatus(response.status)) {
  57. return response;
  58. }
  59. if (attempt >= retries) {
  60. return response; // exhausted — leave body intact for the caller to read
  61. }
  62. // Retriable status with retries left: drain the body to free the socket,
  63. // then back off.
  64. await response.arrayBuffer().catch(() => {});
  65. console.warn(`[tts] HTTP ${response.status}, retrying ${attempt + 1}/${retries}`);
  66. await sleep(delayFor(attempt, baseDelay, maxDelay));
  67. }
  68. throw new Error("fetchWithRetry exhausted retries unexpectedly");
  69. }