@supabase/postgrest-js 2.101.1 → 2.102.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/postgrest-js",
3
- "version": "2.101.1",
3
+ "version": "2.102.0-canary.0",
4
4
  "description": "Isomorphic PostgREST client",
5
5
  "keywords": [
6
6
  "postgrest",
@@ -5,10 +5,66 @@ import type {
5
5
  MergePartialResult,
6
6
  IsValidResultOverride,
7
7
  } from './types/types'
8
- import { ClientServerOptions, Fetch } from './types/common/common'
8
+ import {
9
+ ClientServerOptions,
10
+ Fetch,
11
+ DEFAULT_MAX_RETRIES,
12
+ getRetryDelay,
13
+ RETRYABLE_STATUS_CODES,
14
+ RETRYABLE_METHODS,
15
+ } from './types/common/common'
9
16
  import PostgrestError from './PostgrestError'
10
17
  import { ContainsNull } from './select-query-parser/types'
11
18
 
19
+ /**
20
+ * Sleep for a given number of milliseconds.
21
+ * If an AbortSignal is provided, the sleep resolves early when the signal is aborted.
22
+ */
23
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
24
+ return new Promise((resolve) => {
25
+ if (signal?.aborted) {
26
+ resolve()
27
+ return
28
+ }
29
+ const id = setTimeout(() => {
30
+ signal?.removeEventListener('abort', onAbort)
31
+ resolve()
32
+ }, ms)
33
+ function onAbort() {
34
+ clearTimeout(id)
35
+ resolve()
36
+ }
37
+ signal?.addEventListener('abort', onAbort)
38
+ })
39
+ }
40
+
41
+ /**
42
+ * Check if a request should be retried based on method and status code.
43
+ */
44
+ function shouldRetry(
45
+ method: string,
46
+ status: number,
47
+ attemptCount: number,
48
+ retryEnabled: boolean
49
+ ): boolean {
50
+ // Don't retry if retries are disabled or we've exhausted attempts
51
+ if (!retryEnabled || attemptCount >= DEFAULT_MAX_RETRIES) {
52
+ return false
53
+ }
54
+
55
+ // Only retry idempotent methods (GET, HEAD, OPTIONS)
56
+ if (!RETRYABLE_METHODS.includes(method as (typeof RETRYABLE_METHODS)[number])) {
57
+ return false
58
+ }
59
+
60
+ // Only retry on specific status codes (520 - Cloudflare errors)
61
+ if (!RETRYABLE_STATUS_CODES.includes(status as (typeof RETRYABLE_STATUS_CODES)[number])) {
62
+ return false
63
+ }
64
+
65
+ return true
66
+ }
67
+
12
68
  export default abstract class PostgrestBuilder<
13
69
  ClientOptions extends ClientServerOptions,
14
70
  Result,
@@ -29,6 +85,9 @@ export default abstract class PostgrestBuilder<
29
85
  protected isMaybeSingle: boolean
30
86
  protected urlLengthLimit: number
31
87
 
88
+ // Retry configuration - enabled by default
89
+ protected retryEnabled: boolean = true
90
+
32
91
  /**
33
92
  * Creates a builder configured for a specific PostgREST request.
34
93
  *
@@ -65,6 +124,8 @@ export default abstract class PostgrestBuilder<
65
124
  fetch?: Fetch
66
125
  isMaybeSingle?: boolean
67
126
  urlLengthLimit?: number
127
+ // Retry option
128
+ retry?: boolean
68
129
  }) {
69
130
  this.method = builder.method
70
131
  this.url = builder.url
@@ -75,6 +136,7 @@ export default abstract class PostgrestBuilder<
75
136
  this.signal = builder.signal
76
137
  this.isMaybeSingle = builder.isMaybeSingle ?? false
77
138
  this.urlLengthLimit = builder.urlLengthLimit ?? 8000
139
+ this.retryEnabled = builder.retry ?? true
78
140
 
79
141
  if (builder.fetch) {
80
142
  this.fetch = builder.fetch
@@ -107,9 +169,31 @@ export default abstract class PostgrestBuilder<
107
169
  return this
108
170
  }
109
171
 
110
- /** *
172
+ /**
111
173
  * @category Database
174
+ *
175
+ * Configure retry behavior for this request.
176
+ *
177
+ * By default, retries are enabled for idempotent requests (GET, HEAD, OPTIONS)
178
+ * that fail with network errors or specific HTTP status codes (503, 520).
179
+ * Retries use exponential backoff (1s, 2s, 4s) with a maximum of 3 attempts.
180
+ *
181
+ * @param enabled - Whether to enable retries for this request
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * // Disable retries for a specific query
186
+ * const { data, error } = await supabase
187
+ * .from('users')
188
+ * .select()
189
+ * .retry(false)
190
+ * ```
112
191
  */
192
+ retry(enabled: boolean): this {
193
+ this.retryEnabled = enabled
194
+ return this
195
+ }
196
+
113
197
  then<
114
198
  TResult1 = ThrowOnError extends true
115
199
  ? PostgrestResponseSuccess<Result>
@@ -141,101 +225,74 @@ export default abstract class PostgrestBuilder<
141
225
  // NOTE: Invoke w/o `this` to avoid illegal invocation error.
142
226
  // https://github.com/supabase/postgrest-js/pull/247
143
227
  const _fetch = this.fetch
144
- let res = _fetch(this.url.toString(), {
145
- method: this.method,
146
- headers: this.headers,
147
- body: JSON.stringify(this.body),
148
- signal: this.signal,
149
- }).then(async (res) => {
150
- let error = null
151
- let data = null
152
- let count: number | null = null
153
- let status = res.status
154
- let statusText = res.statusText
155
-
156
- if (res.ok) {
157
- if (this.method !== 'HEAD') {
158
- const body = await res.text()
159
- if (body === '') {
160
- // Prefer: return=minimal
161
- } else if (this.headers.get('Accept') === 'text/csv') {
162
- data = body
163
- } else if (
164
- this.headers.get('Accept') &&
165
- this.headers.get('Accept')?.includes('application/vnd.pgrst.plan+text')
166
- ) {
167
- data = body
168
- } else {
169
- data = JSON.parse(body)
170
- }
171
- }
172
228
 
173
- const countHeader = this.headers.get('Prefer')?.match(/count=(exact|planned|estimated)/)
174
- const contentRange = res.headers.get('content-range')?.split('/')
175
- if (countHeader && contentRange && contentRange.length > 1) {
176
- count = parseInt(contentRange[1])
177
- }
229
+ // Execute fetch with retry logic
230
+ const executeWithRetry = async (): Promise<{
231
+ error: any
232
+ data: any
233
+ count: number | null
234
+ status: number
235
+ statusText: string
236
+ }> => {
237
+ let attemptCount = 0
178
238
 
179
- // Fix for https://github.com/supabase/postgrest-js/issues/361 — applies to all methods.
180
- if (this.isMaybeSingle && Array.isArray(data)) {
181
- if (data.length > 1) {
182
- error = {
183
- // https://github.com/PostgREST/postgrest/blob/a867d79c42419af16c18c3fb019eba8df992626f/src/PostgREST/Error.hs#L553
184
- code: 'PGRST116',
185
- details: `Results contain ${data.length} rows, application/vnd.pgrst.object+json requires 1 row`,
186
- hint: null,
187
- message: 'JSON object requested, multiple (or no) rows returned',
188
- }
189
- data = null
190
- count = null
191
- status = 406
192
- statusText = 'Not Acceptable'
193
- } else if (data.length === 1) {
194
- data = data[0]
195
- } else {
196
- data = null
197
- }
239
+ while (true) {
240
+ const requestHeaders = new Headers(this.headers)
241
+ if (attemptCount > 0) {
242
+ requestHeaders.set('X-Retry-Count', String(attemptCount))
198
243
  }
199
- } else {
200
- const body = await res.text()
201
244
 
245
+ // Only wrap the fetch call itself — processResponse errors must never trigger retries
246
+ let res: Response
202
247
  try {
203
- error = JSON.parse(body)
204
-
205
- // Workaround for https://github.com/supabase/postgrest-js/issues/295
206
- if (Array.isArray(error) && res.status === 404) {
207
- data = []
208
- error = null
209
- status = 200
210
- statusText = 'OK'
248
+ res = await _fetch(this.url.toString(), {
249
+ method: this.method,
250
+ headers: requestHeaders,
251
+ body: JSON.stringify(this.body),
252
+ signal: this.signal,
253
+ })
254
+ } catch (fetchError: any) {
255
+ // Never retry aborted requests
256
+ if (fetchError?.name === 'AbortError' || fetchError?.code === 'ABORT_ERR') {
257
+ throw fetchError
258
+ }
259
+
260
+ // Don't retry network errors for non-idempotent methods
261
+ if (!RETRYABLE_METHODS.includes(this.method as (typeof RETRYABLE_METHODS)[number])) {
262
+ throw fetchError
211
263
  }
212
- } catch {
213
- // Workaround for https://github.com/supabase/postgrest-js/issues/295
214
- if (res.status === 404 && body === '') {
215
- status = 204
216
- statusText = 'No Content'
217
- } else {
218
- error = {
219
- message: body,
220
- }
264
+
265
+ // Check if we should retry network errors
266
+ if (this.retryEnabled && attemptCount < DEFAULT_MAX_RETRIES) {
267
+ const delay = getRetryDelay(attemptCount)
268
+ attemptCount++
269
+ await sleep(delay, this.signal)
270
+ continue
221
271
  }
272
+
273
+ // Exhausted retries or retries disabled, throw the last error
274
+ throw fetchError
222
275
  }
223
276
 
224
- if (error && this.shouldThrowOnError) {
225
- throw new PostgrestError(error)
277
+ // Check if we should retry this HTTP response
278
+ if (shouldRetry(this.method, res.status, attemptCount, this.retryEnabled)) {
279
+ const retryAfterHeader = res.headers?.get('Retry-After') ?? null
280
+ const delay =
281
+ retryAfterHeader !== null
282
+ ? Math.max(0, parseInt(retryAfterHeader, 10) || 0) * 1000
283
+ : getRetryDelay(attemptCount)
284
+ await res.text()
285
+ attemptCount++
286
+ await sleep(delay, this.signal)
287
+ continue
226
288
  }
227
- }
228
289
 
229
- const postgrestResponse = {
230
- error,
231
- data,
232
- count,
233
- status,
234
- statusText,
290
+ return await this.processResponse(res)
235
291
  }
292
+ }
293
+
294
+ let res = executeWithRetry()
236
295
 
237
- return postgrestResponse
238
- })
239
296
  if (!this.shouldThrowOnError) {
240
297
  res = res.catch((fetchError) => {
241
298
  // Build detailed error information including cause if available
@@ -307,6 +364,104 @@ export default abstract class PostgrestBuilder<
307
364
  return res.then(onfulfilled, onrejected)
308
365
  }
309
366
 
367
+ /**
368
+ * Process a fetch response and return the standardized postgrest response.
369
+ */
370
+ private async processResponse(res: Response): Promise<{
371
+ error: any
372
+ data: any
373
+ count: number | null
374
+ status: number
375
+ statusText: string
376
+ }> {
377
+ let error = null
378
+ let data = null
379
+ let count: number | null = null
380
+ let status = res.status
381
+ let statusText = res.statusText
382
+
383
+ if (res.ok) {
384
+ if (this.method !== 'HEAD') {
385
+ const body = await res.text()
386
+ if (body === '') {
387
+ // Prefer: return=minimal
388
+ } else if (this.headers.get('Accept') === 'text/csv') {
389
+ data = body
390
+ } else if (
391
+ this.headers.get('Accept') &&
392
+ this.headers.get('Accept')?.includes('application/vnd.pgrst.plan+text')
393
+ ) {
394
+ data = body
395
+ } else {
396
+ data = JSON.parse(body)
397
+ }
398
+ }
399
+
400
+ const countHeader = this.headers.get('Prefer')?.match(/count=(exact|planned|estimated)/)
401
+ const contentRange = res.headers.get('content-range')?.split('/')
402
+ if (countHeader && contentRange && contentRange.length > 1) {
403
+ count = parseInt(contentRange[1])
404
+ }
405
+
406
+ // Fix for https://github.com/supabase/postgrest-js/issues/361 — applies to all methods.
407
+ if (this.isMaybeSingle && Array.isArray(data)) {
408
+ if (data.length > 1) {
409
+ error = {
410
+ // https://github.com/PostgREST/postgrest/blob/a867d79c42419af16c18c3fb019eba8df992626f/src/PostgREST/Error.hs#L553
411
+ code: 'PGRST116',
412
+ details: `Results contain ${data.length} rows, application/vnd.pgrst.object+json requires 1 row`,
413
+ hint: null,
414
+ message: 'JSON object requested, multiple (or no) rows returned',
415
+ }
416
+ data = null
417
+ count = null
418
+ status = 406
419
+ statusText = 'Not Acceptable'
420
+ } else if (data.length === 1) {
421
+ data = data[0]
422
+ } else {
423
+ data = null
424
+ }
425
+ }
426
+ } else {
427
+ const body = await res.text()
428
+
429
+ try {
430
+ error = JSON.parse(body)
431
+
432
+ // Workaround for https://github.com/supabase/postgrest-js/issues/295
433
+ if (Array.isArray(error) && res.status === 404) {
434
+ data = []
435
+ error = null
436
+ status = 200
437
+ statusText = 'OK'
438
+ }
439
+ } catch {
440
+ // Workaround for https://github.com/supabase/postgrest-js/issues/295
441
+ if (res.status === 404 && body === '') {
442
+ status = 204
443
+ statusText = 'No Content'
444
+ } else {
445
+ error = {
446
+ message: body,
447
+ }
448
+ }
449
+ }
450
+
451
+ if (error && this.shouldThrowOnError) {
452
+ throw new PostgrestError(error)
453
+ }
454
+ }
455
+
456
+ return {
457
+ error,
458
+ data,
459
+ count,
460
+ status,
461
+ statusText,
462
+ }
463
+ }
464
+
310
465
  /**
311
466
  * Override the type of the returned `data`.
312
467
  *
@@ -40,6 +40,9 @@ export default class PostgrestClient<
40
40
  fetch?: Fetch
41
41
  urlLengthLimit: number
42
42
 
43
+ // Retry configuration - enabled by default
44
+ retry?: boolean
45
+
43
46
  // TODO: Add back shouldThrowOnError once we figure out the typings
44
47
  /**
45
48
  * Creates a PostgREST client.
@@ -51,6 +54,10 @@ export default class PostgrestClient<
51
54
  * @param options.fetch - Custom fetch
52
55
  * @param options.timeout - Optional timeout in milliseconds for all requests. When set, requests will automatically abort after this duration to prevent indefinite hangs.
53
56
  * @param options.urlLengthLimit - Maximum URL length in characters before warnings/errors are triggered. Defaults to 8000.
57
+ * @param options.retry - Enable or disable automatic retries for transient errors.
58
+ * When enabled, idempotent requests (GET, HEAD, OPTIONS) that fail with network
59
+ * errors or HTTP 503/520 responses will be automatically retried up to 3 times
60
+ * with exponential backoff (1s, 2s, 4s). Defaults to `true`.
54
61
  * @example
55
62
  * ```ts
56
63
  * import { PostgrestClient } from '@supabase/postgrest-js'
@@ -86,6 +93,7 @@ export default class PostgrestClient<
86
93
  * headers: { apikey: 'public-anon-key' },
87
94
  * schema: 'public',
88
95
  * timeout: 30000, // 30 second timeout
96
+ * retry: false, // Disable automatic retries
89
97
  * })
90
98
  * ```
91
99
  */
@@ -97,12 +105,14 @@ export default class PostgrestClient<
97
105
  fetch,
98
106
  timeout,
99
107
  urlLengthLimit = 8000,
108
+ retry,
100
109
  }: {
101
110
  headers?: HeadersInit
102
111
  schema?: SchemaName
103
112
  fetch?: Fetch
104
113
  timeout?: number
105
114
  urlLengthLimit?: number
115
+ retry?: boolean
106
116
  } = {}
107
117
  ) {
108
118
  this.url = url
@@ -151,6 +161,7 @@ export default class PostgrestClient<
151
161
  } else {
152
162
  this.fetch = originalFetch
153
163
  }
164
+ this.retry = retry
154
165
  }
155
166
  from<
156
167
  TableName extends string & keyof Schema['Tables'],
@@ -179,6 +190,7 @@ export default class PostgrestClient<
179
190
  schema: this.schemaName,
180
191
  fetch: this.fetch,
181
192
  urlLengthLimit: this.urlLengthLimit,
193
+ retry: this.retry,
182
194
  })
183
195
  }
184
196
 
@@ -204,6 +216,7 @@ export default class PostgrestClient<
204
216
  schema,
205
217
  fetch: this.fetch,
206
218
  urlLengthLimit: this.urlLengthLimit,
219
+ retry: this.retry,
207
220
  })
208
221
  }
209
222
 
@@ -442,6 +455,7 @@ export default class PostgrestClient<
442
455
  body,
443
456
  fetch: this.fetch ?? fetch,
444
457
  urlLengthLimit: this.urlLengthLimit,
458
+ retry: this.retry,
445
459
  })
446
460
  }
447
461
  }
@@ -22,18 +22,34 @@ export default class PostgrestQueryBuilder<
22
22
  fetch?: Fetch
23
23
  urlLengthLimit: number
24
24
 
25
+ /**
26
+ * Enable or disable automatic retries for transient errors.
27
+ * When enabled, idempotent requests (GET/HEAD/OPTIONS) that fail with network
28
+ * errors or HTTP 503/520 responses are automatically retried with exponential
29
+ * backoff (1s, 2s, 4s, up to 3 attempts). Defaults to `true` when not specified.
30
+ */
31
+ retry?: boolean
32
+
25
33
  /**
26
34
  * Creates a query builder scoped to a Postgres table or view.
27
35
  *
28
36
  * @category Database
29
37
  *
38
+ * @param url - The URL for the query
39
+ * @param options - Named parameters
40
+ * @param options.headers - Custom headers
41
+ * @param options.schema - Postgres schema to use
42
+ * @param options.fetch - Custom fetch implementation
43
+ * @param options.urlLengthLimit - Maximum URL length before warning
44
+ * @param options.retry - Enable automatic retries for transient errors (default: true)
45
+ *
30
46
  * @example Creating a Postgrest query builder
31
47
  * ```ts
32
48
  * import { PostgrestQueryBuilder } from '@supabase/postgrest-js'
33
49
  *
34
50
  * const query = new PostgrestQueryBuilder(
35
51
  * new URL('https://xyzcompany.supabase.co/rest/v1/users'),
36
- * { headers: { apikey: 'public-anon-key' } }
52
+ * { headers: { apikey: 'public-anon-key' }, retry: true }
37
53
  * )
38
54
  * ```
39
55
  */
@@ -44,11 +60,13 @@ export default class PostgrestQueryBuilder<
44
60
  schema,
45
61
  fetch,
46
62
  urlLengthLimit = 8000,
63
+ retry,
47
64
  }: {
48
65
  headers?: HeadersInit
49
66
  schema?: string
50
67
  fetch?: Fetch
51
68
  urlLengthLimit?: number
69
+ retry?: boolean
52
70
  }
53
71
  ) {
54
72
  this.url = url
@@ -56,6 +74,7 @@ export default class PostgrestQueryBuilder<
56
74
  this.schema = schema
57
75
  this.fetch = fetch
58
76
  this.urlLengthLimit = urlLengthLimit
77
+ this.retry = retry
59
78
  }
60
79
 
61
80
  /**
@@ -904,6 +923,7 @@ export default class PostgrestQueryBuilder<
904
923
  schema: this.schema,
905
924
  fetch: this.fetch,
906
925
  urlLengthLimit: this.urlLengthLimit,
926
+ retry: this.retry,
907
927
  })
908
928
  }
909
929
 
@@ -1092,6 +1112,7 @@ export default class PostgrestQueryBuilder<
1092
1112
  body: values,
1093
1113
  fetch: this.fetch ?? fetch,
1094
1114
  urlLengthLimit: this.urlLengthLimit,
1115
+ retry: this.retry,
1095
1116
  })
1096
1117
  }
1097
1118
 
@@ -1389,6 +1410,7 @@ export default class PostgrestQueryBuilder<
1389
1410
  body: values,
1390
1411
  fetch: this.fetch ?? fetch,
1391
1412
  urlLengthLimit: this.urlLengthLimit,
1413
+ retry: this.retry,
1392
1414
  })
1393
1415
  }
1394
1416
 
@@ -1562,6 +1584,7 @@ export default class PostgrestQueryBuilder<
1562
1584
  body: values,
1563
1585
  fetch: this.fetch ?? fetch,
1564
1586
  urlLengthLimit: this.urlLengthLimit,
1587
+ retry: this.retry,
1565
1588
  })
1566
1589
  }
1567
1590
 
@@ -1710,6 +1733,7 @@ export default class PostgrestQueryBuilder<
1710
1733
  schema: this.schema,
1711
1734
  fetch: this.fetch ?? fetch,
1712
1735
  urlLengthLimit: this.urlLengthLimit,
1736
+ retry: this.retry,
1713
1737
  })
1714
1738
  }
1715
1739
  }
@@ -2,6 +2,33 @@
2
2
 
3
3
  export type Fetch = typeof fetch
4
4
 
5
+ /**
6
+ * Default number of retry attempts.
7
+ */
8
+ export const DEFAULT_MAX_RETRIES = 3
9
+
10
+ /**
11
+ * Default exponential backoff delay function.
12
+ * Delays: 1s, 2s, 4s, 8s, ... (max 30s)
13
+ *
14
+ * @param attemptIndex - Zero-based index of the retry attempt
15
+ * @returns Delay in milliseconds before the next retry
16
+ */
17
+ export const getRetryDelay = (attemptIndex: number): number =>
18
+ Math.min(1000 * 2 ** attemptIndex, 30000)
19
+
20
+ /**
21
+ * Status codes that are safe to retry.
22
+ * 520 = Cloudflare timeout/connection errors (transient)
23
+ * 503 = PostgREST schema cache not yet loaded (transient, signals retry via Retry-After header)
24
+ */
25
+ export const RETRYABLE_STATUS_CODES = [520, 503] as const
26
+
27
+ /**
28
+ * HTTP methods that are safe to retry (idempotent operations).
29
+ */
30
+ export const RETRYABLE_METHODS = ['GET', 'HEAD', 'OPTIONS'] as const
31
+
5
32
  export type GenericRelationship = {
6
33
  foreignKeyName: string
7
34
  columns: string[]
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  // - Debugging and support (identifying which version is running)
5
5
  // - Telemetry and logging (version reporting in errors/analytics)
6
6
  // - Ensuring build artifacts match the published package version
7
- export const version = '2.101.1'
7
+ export const version = '2.102.0-canary.0'