Skip to content

feat(postgrest): add automatic retry for transient failures#1338

Merged
grdsdev merged 10 commits into
mainfrom
grdsdev/postgrest-retry-flutter
Apr 7, 2026
Merged

feat(postgrest): add automatic retry for transient failures#1338
grdsdev merged 10 commits into
mainfrom
grdsdev/postgrest-retry-flutter

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented Mar 26, 2026

Summary

Implements automatic retry logic for the Flutter PostgREST client, mirroring the Swift SDK (SDK-771) and the supabase-js reference implementation.

  • Only retries idempotent methods (GET and HEAD) on HTTP 503, 520, or network errors
  • Up to 3 retries with exponential backoff: 1s → 2s → 4s (capped at 30s)
  • Adds X-Retry-Count: <n> header on each retry attempt
  • Enabled by default; configurable globally or per-request

Changes

  • postgrest.dart: Added retryEnabled parameter to PostgrestClient (default true); passes retry config into all builders created by from(), rpc(), and schema()
  • postgrest_builder.dart: Added _clientRetryEnabled, _retryEnabled (per-request), and _retryDelay fields; added retry({required bool enabled}) method; extracted _executeWithRetry() wrapping the HTTP call with retry loop; added dart:math import
    • _execute() now works with a per-execution copy of headers (execHeaders) so that _headers is never mutated — fixes header leakage across repeated awaits and between sibling builders that share the same headers map reference
  • postgrest_query_builder.dart / postgrest_rpc_builder.dart: Accept and propagate clientRetryEnabled / retryDelay constructor params
  • raw_postgrest_builder.dart / response_postgrest_builder.dart: Copy retry fields in copy constructors and withConverter()
  • test/retry_test.dart (new): 12 unit tests using a mock http.Client covering all retry scenarios

Testing

New Test Coverage (12 tests, all passing)

  • GET retries on 520 → 200 (verifies X-Retry-Count header increments)
  • HEAD retries on 520 → 200
  • GET retries on 503 → 200
  • POST does not retry on 520
  • GET does not retry on non-520 (e.g., 400)
  • GET retries on SocketException network error
  • POST does not retry on network error
  • Exhausts all 3 retries (4 total calls) then throws
  • .retry(enabled: false) disables retry per-request
  • PostgrestClient(retryEnabled: false) disables retry globally
  • .retry(enabled: true) re-enables retry overriding client-level false
  • GET exhausts retries on repeated network errors then rethrows

Tests use zero-duration delay override (retryDelay: (_) => Duration.zero) to run instantly.

Existing Tests

All existing custom HTTP client tests continue to pass. Integration tests (requiring a live PostgREST server) are unaffected.

Risk Assessment

  • Breaking changes: None — retryEnabled defaults to true, retryDelay is @visibleForTesting
  • Backward compatibility: Maintained
  • Performance impact: Negligible for successful requests (no delay added)
  • Security implications: None

Acceptance Criteria

  • Retry logic only applies to GET and HEAD
  • HTTP 503, 520, and network errors trigger retries; other status codes do not
  • Exponential backoff: 1s, 2s, 4s (capped at 30s)
  • X-Retry-Count header present on retried requests, does not leak to non-retry requests
  • retryEnabled: false on PostgrestClient disables globally
  • .retry(enabled: false/true) overrides per request
  • All existing tests pass
  • New tests cover all retry scenarios

Closes: SDK-785


🤖 Generated with Claude Code /take

Implements retry logic for the Flutter PostgREST client, mirroring the
Swift SDK (SDK-771) and supabase-js reference implementations.

Key behavior:
- Only retries idempotent methods: GET and HEAD
- Retry conditions: HTTP 520 or network/connection error
- Up to 3 retries with exponential backoff: 1s → 2s → 4s (capped at 30s)
- Adds X-Retry-Count: <n> header on each retry attempt
- Enabled by default; disable globally via PostgrestClient(retryEnabled: false)
- Per-request override via .retry(enabled: false/true)

Acceptance Criteria:
- [x] Retry logic only applies to GET and HEAD
- [x] HTTP 520 and network errors trigger retries; other status codes do not
- [x] Exponential backoff: 1s, 2s, 4s (capped at 30s)
- [x] X-Retry-Count header present on retried requests
- [x] retryEnabled: false on PostgrestClient disables globally
- [x] .retry(enabled: false/true) overrides per request
- [x] All existing tests pass
- [x] 11 new tests cover all retry scenarios

Linear: SDK-785

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 26, 2026 18:28
@github-actions github-actions Bot added the postgrest This issue or pull request is related to postgrest label Mar 26, 2026
Copy link
Copy Markdown

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 automatic retry behavior to the Dart/Flutter PostgREST client for transient failures, aligning behavior with other Supabase SDKs and improving resiliency for idempotent reads.

Changes:

  • Introduces configurable automatic retries (default enabled) for GET/HEAD on HTTP 520 and network exceptions, including X-Retry-Count header and exponential backoff.
  • Propagates retry configuration through the various builder types and copy/transform flows.
  • Adds a dedicated unit test suite covering retry scenarios with a mock http.Client.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/postgrest/lib/src/postgrest.dart Adds global retryEnabled (default true) and test-only retryDelay, and forwards them into builders.
packages/postgrest/lib/src/postgrest_builder.dart Implements retry loop, per-request override API (retry(enabled: ...)), and default exponential backoff.
packages/postgrest/lib/src/postgrest_query_builder.dart Accepts/propagates client retry settings into underlying PostgrestBuilder.
packages/postgrest/lib/src/postgrest_rpc_builder.dart Accepts/propagates client retry settings into underlying PostgrestBuilder.
packages/postgrest/lib/src/raw_postgrest_builder.dart Ensures retry fields are preserved across copy/withConverter paths.
packages/postgrest/lib/src/response_postgrest_builder.dart Ensures retry fields are preserved across response builder cloning/withConverter paths.
packages/postgrest/test/retry_test.dart Adds unit tests validating retry eligibility, headers, exhaustion behavior, and per-request/global overrides.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/postgrest/lib/src/postgrest_builder.dart
grdsdev and others added 3 commits March 26, 2026 15:45
- Use initializing formal (this.retryEnabled) to fix prefer_initializing_formals
- Remove @VisibleForTesting from retryDelay in PostgrestQueryBuilder and
  PostgrestRpcBuilder since they are called from production code in
  postgrest.dart; keep annotation only on PostgrestClient and PostgrestBuilder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_execute() was writing Prefer, Accept-Profile, Content-Profile,
Content-Type, and X-Retry-Count directly into _headers. Because
_copyWith passes the same map reference when headers are not
overridden, sibling builders share the map, and awaiting a builder
more than once accumulates mutations.

Switch to a per-execution local copy (execHeaders) so that _headers
is never mutated, retry headers don't leak across requests, and
repeated awaits behave correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
503 is a standard transient error (load shedding, rolling restarts)
that should be retried the same way as 520.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

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

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/postgrest/lib/src/postgrest_builder.dart
Comment thread packages/postgrest/lib/src/postgrest_builder.dart
Comment thread packages/postgrest/lib/src/postgrest.dart Outdated
retry() on the base PostgrestBuilder returned PostgrestBuilder<T,S,R>,
losing the concrete subclass type and breaking chains like
from(...).select().retry(...).single() or
from(...).retry(...).select().eq(...).

Override retry() in PostgrestTransformBuilder, PostgrestFilterBuilder,
and PostgrestQueryBuilder following the same pattern as setHeader().
Also add retryEnabled param to PostgrestQueryBuilder constructor so
setHeader() and retry() both propagate the per-request override
correctly.

Additionally update the PostgrestClient doc comment to mention HTTP 503
as a retryable status code alongside 520.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@Vinzent03 Vinzent03 left a comment

Choose a reason for hiding this comment

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

I'm not familiar with how it is done in the other libraries, but to reduce the number of arguments for the builder classes. What about removing the clientRetryEnabled in the builder and just setting retryEnabled on construction to the client value and making it non-nullable. I think this would make the behavior cleaner. The retryEnabled can than be changed by the .retry method.
In addition I would propose to make the retryDelay configurable in the .retry method, because is already stored in the builder so why not.

grdsdev and others added 5 commits April 6, 2026 11:18
Main's SDK alignment commit (e37539e) removed ! assertions on
nullable fields (_count, _schema, _isolate, _converter) expecting
Dart field promotion to handle them. However, field promotion is
blocked here because PostgrestBuilder has subclasses in the same
library, preventing the analyzer from guaranteeing the field value
hasn't changed. Restore ! assertions inside the null-checked
contexts to fix dart analyze --fatal-warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-nullable

Implements Vinzent's review suggestion: instead of carrying both
_clientRetryEnabled and _retryEnabled (nullable) through every builder,
set _retryEnabled to the client value on construction and keep it
non-nullable. The .retry() method overrides it directly.

Also removes unnecessary null-assertion operators (!) that Dart now
correctly identifies as redundant via field promotion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grdsdev grdsdev merged commit fb1da8b into main Apr 7, 2026
18 checks passed
@grdsdev grdsdev deleted the grdsdev/postgrest-retry-flutter branch April 7, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

postgrest This issue or pull request is related to postgrest

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants