feat(postgrest): add automatic retry for transient failures#1338
feat(postgrest): add automatic retry for transient failures#1338
Conversation
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>
There was a problem hiding this comment.
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-Countheader 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.
| for (var attempt = 0; attempt <= maxRetries; attempt++) { | ||
| if (attempt > 0) { | ||
| _headers['X-Retry-Count'] = attempt.toString(); | ||
| } | ||
|
|
||
| try { | ||
| final response = await send(); | ||
| if (!retryableStatusCodes.contains(response.statusCode) || | ||
| attempt == maxRetries) { | ||
| return response; | ||
| } | ||
| } on Exception { | ||
| if (attempt == maxRetries) rethrow; | ||
| } | ||
|
|
||
| await Future.delayed(_retryDelay(attempt)); | ||
| } | ||
|
|
||
| throw StateError('unreachable'); |
There was a problem hiding this comment.
_executeWithRetry mutates the builder's shared _headers map by setting X-Retry-Count. Since builders can be executed multiple times (then calls _execute() each time) and multiple derived builders often share the same headers map via _copyWithType(headers: headers ?? _headers), this header can leak into subsequent non-retry attempts/requests (and can also overwrite a user-provided X-Retry-Count). Consider keeping retry headers per-attempt (e.g., pass a fresh headers map into send each attempt) and/or restoring/removing X-Retry-Count after the retry loop completes.
| for (var attempt = 0; attempt <= maxRetries; attempt++) { | |
| if (attempt > 0) { | |
| _headers['X-Retry-Count'] = attempt.toString(); | |
| } | |
| try { | |
| final response = await send(); | |
| if (!retryableStatusCodes.contains(response.statusCode) || | |
| attempt == maxRetries) { | |
| return response; | |
| } | |
| } on Exception { | |
| if (attempt == maxRetries) rethrow; | |
| } | |
| await Future.delayed(_retryDelay(attempt)); | |
| } | |
| throw StateError('unreachable'); | |
| final originalRetryHeader = _headers['X-Retry-Count']; | |
| try { | |
| for (var attempt = 0; attempt <= maxRetries; attempt++) { | |
| if (attempt > 0 && originalRetryHeader == null) { | |
| _headers['X-Retry-Count'] = attempt.toString(); | |
| } | |
| try { | |
| final response = await send(); | |
| if (!retryableStatusCodes.contains(response.statusCode) || | |
| attempt == maxRetries) { | |
| return response; | |
| } | |
| } on Exception { | |
| if (attempt == maxRetries) rethrow; | |
| } | |
| await Future.delayed(_retryDelay(attempt)); | |
| } | |
| throw StateError('unreachable'); | |
| } finally { | |
| if (originalRetryHeader != null) { | |
| _headers['X-Retry-Count'] = originalRetryHeader; | |
| } else { | |
| _headers.remove('X-Retry-Count'); | |
| } | |
| } |
- 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>
Summary
Implements automatic retry logic for the Flutter PostgREST client, mirroring the Swift SDK (SDK-771) and the supabase-js reference implementation.
X-Retry-Count: <n>header on each retry attemptChanges
postgrest.dart: AddedretryEnabledparameter toPostgrestClient(defaulttrue); passes retry config into all builders created byfrom(),rpc(), andschema()postgrest_builder.dart: Added_clientRetryEnabled,_retryEnabled(per-request), and_retryDelayfields; addedretry({required bool enabled})method; extracted_executeWithRetry()wrapping the HTTP call with retry loop; addeddart:mathimportpostgrest_query_builder.dart/postgrest_rpc_builder.dart: Accept and propagateclientRetryEnabled/retryDelayconstructor paramsraw_postgrest_builder.dart/response_postgrest_builder.dart: Copy retry fields in copy constructors andwithConverter()test/retry_test.dart(new): 11 unit tests using a mockhttp.Clientcovering all retry scenariosTesting
New Test Coverage (11 tests, all passing)
X-Retry-Countheader increments)SocketExceptionnetwork error.retry(enabled: false)disables retry per-requestPostgrestClient(retryEnabled: false)disables retry globally.retry(enabled: true)re-enables retry overriding client-levelfalseTests 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
retryEnableddefaults totrue,retryDelayis@visibleForTestingAcceptance Criteria
X-Retry-Countheader present on retried requestsretryEnabled: falseonPostgrestClientdisables globally.retry(enabled: false/true)overrides per requestCloses: SDK-785
🤖 Generated with Claude Code
/take