Skip to content

feat(postgrest): add automatic retries for transient errors#927

Merged
grdsdev merged 5 commits intomainfrom
guilherme/sdk-771-featpostgrest-add-automatic-retries-for-transient-errors
Mar 23, 2026
Merged

feat(postgrest): add automatic retries for transient errors#927
grdsdev merged 5 commits intomainfrom
guilherme/sdk-771-featpostgrest-add-automatic-retries-for-transient-errors

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented Mar 18, 2026

Summary

  • Adds transparent retry logic to PostgrestBuilder for transient failures (HTTP 520, network errors), mirroring supabase-js#2072
  • Retries are enabled by default and only apply to idempotent methods (GET, HEAD)
  • Up to 3 retries with exponential backoff (1s → 2s → 4s, capped at 30s) using the existing _clock abstraction from Helpers

Changes

  • PostgrestClient.Configuration: new retryEnabled: Bool field (default true); threaded through both init overloads
  • PostgrestBuilder: retryEnabled in MutableState (propagated through copy constructor); .retry(enabled:) chainable method; private execute wrapped in retry loop with X-Retry-Count header on retried requests
  • PostgrestBuilderTests: 10 new tests covering all retry scenarios; uses ImmediateRetryTestClock to skip sleep delays in tests

Retry behavior

Condition GET/HEAD POST/PATCH/DELETE
HTTP 520 retried (up to 3x) never retried
Network error retried (up to 3x) never retried
HTTP 4xx/5xx (non-520) not retried not retried

Configuration

// Disable globally
let client = PostgrestClient(url: url, retryEnabled: false)

// Disable per-request
try await supabase.from("users").select().retry(enabled: false).execute()

// Re-enable per-request (overrides client-level false)
try await supabase.from("users").select().retry(enabled: true).execute()

Test plan

  • Retry succeeds on 520 → 200 for GET (verifies X-Retry-Count header)
  • Retry succeeds on 520 → 200 for HEAD
  • No retry on 520 for POST
  • No retry on non-520 error (400) for GET
  • Retry on network error for GET
  • No retry on network error for POST
  • Exhausts all 3 retries then throws (4 total calls)
  • Per-request .retry(enabled: false) disables retry
  • Client-level retryEnabled: false disables retry
  • Per-request .retry(enabled: true) overrides client-level false
  • All 19 tests in PostgrestBuilderTests pass

Closes: SDK-771


🤖 Generated with Claude Code /take

Copilot AI review requested due to automatic review settings March 18, 2026 15:07
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds configurable automatic retry behavior to PostgREST requests to improve resilience against transient failures, along with test coverage for the new behavior.

Changes:

  • Introduces retryEnabled at the PostgrestClient configuration level (defaulting to enabled).
  • Adds per-request retry(enabled:) override plus retry logic (HTTP 520 + thrown network errors) for idempotent methods (GET/HEAD), including X-Retry-Count header.
  • Extends/adjusts tests to validate retry behavior and keep request-building snapshots deterministic.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
Sources/PostgREST/PostgrestClient.swift Adds retryEnabled configuration and initializer parameter.
Sources/PostgREST/PostgrestBuilder.swift Implements retry loop + per-request override + X-Retry-Count header.
Tests/PostgRESTTests/PostgrestBuilderTests.swift Adds retry-focused unit tests and a no-op clock for fast retries.
Tests/PostgRESTTests/BuildURLRequestTests.swift Updates test isolation primitive and disables retries to avoid impacting snapshots.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread Tests/PostgRESTTests/BuildURLRequestTests.swift
Comment thread Sources/PostgREST/PostgrestBuilder.swift
grdsdev and others added 5 commits March 19, 2026 17:11
Adds transparent retry logic to the PostgREST client for transient
failures (HTTP 520, network errors), mirroring supabase-js#2072.

Retry behavior:
- Only retries on HTTP 520 or network errors
- Only retries idempotent methods (GET, HEAD)
- Up to 3 retries with exponential backoff (1s, 2s, 4s, capped at 30s)
- Adds X-Retry-Count header on retried requests

Configuration:
- Enabled by default; disable globally via `retryEnabled: false` on
  PostgrestClient or PostgrestClient.Configuration
- Per-request override via `.retry(enabled: false/true)` on the builder

Linear: SDK-771

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix bug where decode errors could be incorrectly retried: separate the
  network send from decoding so only transient network/HTTP errors trigger
  retries
- Remove fetchOptions from MutableState; build the final request as a
  local snapshot to avoid mutable side effects on execute
- Move maxRetries, retryableMethods, retryableStatusCodes to private static
  let constants
- Mark decode closure as @sendable for StrictConcurrency correctness
- Add Task.checkCancellation() at the top of each retry iteration
- Refactor shouldRetry to use guard chains and Self. static references
- Fix typo in log message: "Fail to decode" -> "Failed to decode"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix variable shadowing bug in PostgrestBuilder.execute where
  `var request = baseRequest` inside the retry loop overwrote the
  prepared request (with Accept/Content-Type headers), causing snapshot
  tests to fail. Renamed to `currentRequest` to avoid the shadowing.

- Add tearDown to PostgrestBuilderTests to restore `_clock` after each
  test. setUp was setting _clock to ImmediateRetryTestClock() but never
  restoring it, causing subsequent Realtime timeout tests to fire
  immediately and return TimeoutError instead of expected responses.
  Made `_resolveClock` package-accessible to support the reset.

- Fix TOCTOU force-unwrap crash in RealtimeClientV2.sendHeartbeat where
  pendingHeartbeatRef was set in one LockIsolated closure then
  force-unwrapped in a second closure, allowing a race where another
  thread could clear the ref between reads. Now captures and returns the
  ref in a single closure using an Optional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LockIsolated.withValue is synchronous; the await was a leftover from
when the variable was ActorIsolated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grdsdev grdsdev force-pushed the guilherme/sdk-771-featpostgrest-add-automatic-retries-for-transient-errors branch from 8063df3 to 0790329 Compare March 19, 2026 20:11
@grdsdev grdsdev requested a review from a team March 20, 2026 14:20
Copy link
Copy Markdown

@mandarini mandarini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we enable the retries by default? I guess it's safe since it's only for get/head, but still it's a behaviour change, we should stress it in the docs (I see you do). In any case, should we also retry on 502/503/504?

@grdsdev
Copy link
Copy Markdown
Contributor Author

grdsdev commented Mar 23, 2026

Should we enable the retries by default? I guess it's safe since it's only for get/head, but still it's a behaviour change, we should stress it in the docs (I see you do). In any case, should we also retry on 502/503/504?

@mandarini It is enabled by default already and we should only retry on 520 as per project definition.

@grdsdev grdsdev merged commit fe75f12 into main Mar 23, 2026
22 of 23 checks passed
@grdsdev grdsdev deleted the guilherme/sdk-771-featpostgrest-add-automatic-retries-for-transient-errors branch March 23, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants