File size: 4,253 Bytes
96c8713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import {
  DEFAULT_MAX_RETRIES,
  Fetch,
  getRetryDelay,
  RETRYABLE_METHODS,
  RETRYABLE_STATUS_CODES,
} from './types/common/common'

/**
 * Sleep for a given number of milliseconds.
 * If an AbortSignal is provided, the sleep resolves early when the signal is aborted.
 */
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve) => {
    if (signal?.aborted) {
      resolve()
      return
    }
    const id = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort)
      resolve()
    }, ms)
    function onAbort() {
      clearTimeout(id)
      resolve()
    }
    signal?.addEventListener('abort', onAbort)
  })
}

/**
 * Check if a request should be retried based on method and status code.
 */
function shouldRetry(
  method: string,
  status: number,
  attemptCount: number,
  retryEnabled: boolean
): boolean {
  // Don't retry if retries are disabled or we've exhausted attempts
  if (!retryEnabled || attemptCount >= DEFAULT_MAX_RETRIES) {
    return false
  }

  // Only retry idempotent methods (GET, HEAD, OPTIONS)
  if (!RETRYABLE_METHODS.includes(method as (typeof RETRYABLE_METHODS)[number])) {
    return false
  }

  // Only retry on specific status codes (520 - Cloudflare errors)
  if (!RETRYABLE_STATUS_CODES.includes(status as (typeof RETRYABLE_STATUS_CODES)[number])) {
    return false
  }

  return true
}

export interface RetryableRequest {
  method: string
  /** Plain object, never a Headers instance: React Native's XHR-based fetch drops the latter. */
  headers: Record<string, string>
  body?: string
  signal?: AbortSignal
}

/**
 * Perform a request with the retry policy shared by every PostgREST call.
 *
 * Idempotent methods (GET, HEAD, OPTIONS) are retried up to
 * `DEFAULT_MAX_RETRIES` times when the fetch rejects or the server answers
 * with a retryable status (503, 520). The wait honours the `Retry-After`
 * header when present and backs off exponentially otherwise. Retried
 * attempts carry an `X-Retry-Count` header. Aborted requests and
 * non-idempotent methods are never retried: their rejection propagates
 * unchanged. A response body is drained before its request is retried.
 */
export async function fetchWithRetry(
  fetchImpl: Fetch,
  url: string,
  request: RetryableRequest,
  retryEnabled: boolean
): Promise<Response> {
  let attemptCount = 0

  while (true) {
    const headers: Record<string, string> = { ...request.headers }
    if (attemptCount > 0) {
      headers['X-Retry-Count'] = String(attemptCount)
    }

    let res: Response
    try {
      res = await fetchImpl(url, {
        method: request.method,
        headers,
        body: request.body,
        signal: request.signal,
      })
      // JS allows throwing any value, and serverless or realm-crossing fetch
      // implementations can reject with non-Error objects. `instanceof Error`
      // is too narrow here; narrow at the use site with optional chaining.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } catch (fetchError: any) {
      // Never retry aborted requests
      if (fetchError?.name === 'AbortError' || fetchError?.code === 'ABORT_ERR') {
        throw fetchError
      }

      // Don't retry network errors for non-idempotent methods
      if (!RETRYABLE_METHODS.includes(request.method as (typeof RETRYABLE_METHODS)[number])) {
        throw fetchError
      }

      // Check if we should retry network errors
      if (retryEnabled && attemptCount < DEFAULT_MAX_RETRIES) {
        const delay = getRetryDelay(attemptCount)
        attemptCount++
        await sleep(delay, request.signal)
        continue
      }

      // Exhausted retries or retries disabled, throw the last error
      throw fetchError
    }

    // Check if we should retry this HTTP response
    if (shouldRetry(request.method, res.status, attemptCount, retryEnabled)) {
      const retryAfterHeader = res.headers?.get('Retry-After') ?? null
      const delay =
        retryAfterHeader !== null
          ? Math.max(0, parseInt(retryAfterHeader, 10) || 0) * 1000
          : getRetryDelay(attemptCount)
      await res.text()
      attemptCount++
      await sleep(delay, request.signal)
      continue
    }

    return res
  }
}