Skip to content

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

Open
grdsdev wants to merge 3 commits intomainfrom
grdsdev/postgrest-retry-flutter
Open

feat(postgrest): add automatic retry for transient failures#1338
grdsdev wants to merge 3 commits intomainfrom
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 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
  • 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): 11 unit tests using a mock http.Client covering all retry scenarios

Testing

New Test Coverage (11 tests, all passing)

  • GET retries on 520 → 200 (verifies X-Retry-Count header increments)
  • HEAD retries on 520 → 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 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
  • 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 on lines +217 to +235
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');
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

_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.

Suggested change
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');
}
}

Copilot uses AI. Check for mistakes.
grdsdev and others added 2 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>
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.

2 participants