Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/postgrest/lib/src/postgrest.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:postgrest/postgrest.dart';
import 'package:postgrest/src/constants.dart';
import 'package:yet_another_json_isolate/yet_another_json_isolate.dart';
Expand All @@ -12,6 +13,8 @@ class PostgrestClient {
final Client? httpClient;
final YAJsonIsolate _isolate;
final bool _hasCustomIsolate;
final bool retryEnabled;
final Duration Function(int attempt)? _retryDelay;
final _log = Logger('supabase.postgrest');

/// To create a [PostgrestClient], you need to provide an [url] endpoint.
Expand All @@ -25,16 +28,23 @@ class PostgrestClient {
/// [httpClient] is optional and can be used to provide a custom http client
///
/// [isolate] is optional and can be used to provide a custom isolate, which is used for heavy json computation
///
/// [retryEnabled] controls whether automatic retries are performed for GET and
/// HEAD requests that fail with HTTP 520 or a network error. Defaults to `true`.
/// Use [PostgrestBuilder.retry] to override this per request.
PostgrestClient(
this.url, {
Map<String, String>? headers,
String? schema,
this.httpClient,
YAJsonIsolate? isolate,
this.retryEnabled = true,
@visibleForTesting Duration Function(int attempt)? retryDelay,
}) : _schema = schema,
headers = {...defaultHeaders, if (headers != null) ...headers},
_isolate = isolate ?? (YAJsonIsolate()..initialize()),
_hasCustomIsolate = isolate != null {
_hasCustomIsolate = isolate != null,
_retryDelay = retryDelay {
_log.config('Initialize PostgrestClient with url: $url, schema: $_schema');
_log.finest('Initialize with headers: $headers');
}
Expand Down Expand Up @@ -65,6 +75,8 @@ class PostgrestClient {
schema: _schema,
httpClient: httpClient,
isolate: _isolate,
clientRetryEnabled: retryEnabled,
retryDelay: _retryDelay,
);
}

Expand All @@ -78,6 +90,8 @@ class PostgrestClient {
schema: schema,
httpClient: httpClient,
isolate: _isolate,
retryEnabled: retryEnabled,
retryDelay: _retryDelay,
);
}

Expand Down Expand Up @@ -108,6 +122,8 @@ class PostgrestClient {
schema: _schema,
httpClient: httpClient,
isolate: _isolate,
clientRetryEnabled: retryEnabled,
retryDelay: _retryDelay,
).rpc(params, get);
}

Expand Down
137 changes: 101 additions & 36 deletions packages/postgrest/lib/src/postgrest_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:core';
import 'dart:math' as math;

import 'package:http/http.dart' as http;
import 'package:http/http.dart';
Expand Down Expand Up @@ -45,8 +46,14 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
final Client? _httpClient;
final YAJsonIsolate? _isolate;
final CountOption? _count;
final bool _clientRetryEnabled;
final bool? _retryEnabled;
final Duration Function(int attempt) _retryDelay;
final _log = Logger('supabase.postgrest');

static Duration _defaultRetryDelay(int attempt) =>
Duration(seconds: math.min(math.pow(2, attempt).toInt(), 30));

PostgrestBuilder({
required Uri url,
required Headers headers,
Expand All @@ -58,6 +65,9 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
CountOption? count,
bool maybeSingle = false,
PostgrestConverter<S, R>? converter,
bool clientRetryEnabled = true,
bool? retryEnabled,
@visibleForTesting Duration Function(int attempt)? retryDelay,
}) : _maybeSingle = maybeSingle,
_method = method,
_converter = converter,
Expand All @@ -67,7 +77,10 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
_httpClient = httpClient,
_isolate = isolate,
_count = count,
_body = body;
_body = body,
_clientRetryEnabled = clientRetryEnabled,
_retryEnabled = retryEnabled,
_retryDelay = retryDelay ?? _defaultRetryDelay;

PostgrestBuilder<T, S, R> _copyWith({
Uri? url,
Expand All @@ -80,6 +93,9 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
CountOption? count,
bool? maybeSingle,
PostgrestConverter<S, R>? converter,
bool? clientRetryEnabled,
bool? retryEnabled,
Duration Function(int attempt)? retryDelay,
}) {
return PostgrestBuilder<T, S, R>(
url: url ?? _url,
Expand All @@ -92,9 +108,21 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
count: count ?? _count,
maybeSingle: maybeSingle ?? _maybeSingle,
converter: converter ?? _converter,
clientRetryEnabled: clientRetryEnabled ?? _clientRetryEnabled,
retryEnabled: retryEnabled ?? _retryEnabled,
retryDelay: retryDelay ?? _retryDelay,
);
}

/// Overrides the retry behavior for this specific request.
///
/// When [enabled] is `false`, retries are disabled for this request even if
/// [PostgrestClient] was configured with `retryEnabled: true`.
/// When [enabled] is `true`, retries are enabled for this request even if
/// [PostgrestClient] was configured with `retryEnabled: false`.
PostgrestBuilder<T, S, R> retry({required bool enabled}) =>
_copyWith(retryEnabled: enabled);

PostgrestBuilder<T, S, R> setHeader(String key, String value) {
return _copyWith(
headers: {..._headers, key: value},
Expand All @@ -103,13 +131,17 @@ class PostgrestBuilder<T, S, R> implements Future<T> {

Future<T> _execute() async {
final String? method = _method;
// Work with a local copy so repeated awaits and shared-map siblings are
// not affected by per-execution header mutations (Prefer, schema headers,
// X-Retry-Count, etc.).
final execHeaders = {..._headers};

if (_count != null) {
if (_headers['Prefer'] != null) {
final oldPreferHeader = _headers['Prefer'];
_headers['Prefer'] = '$oldPreferHeader,count=${_count!.name}';
if (execHeaders['Prefer'] != null) {
final oldPreferHeader = execHeaders['Prefer'];
execHeaders['Prefer'] = '$oldPreferHeader,count=${_count!.name}';
} else {
_headers['Prefer'] = 'count=${_count!.name}';
execHeaders['Prefer'] = 'count=${_count!.name}';
}
}

Expand All @@ -121,62 +153,95 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
}

final uppercaseMethod = method.toUpperCase();
late http.Response response;

if (_schema == null) {
// skip
} else if ([METHOD_GET, METHOD_HEAD].contains(method)) {
_headers['Accept-Profile'] = _schema!;
execHeaders['Accept-Profile'] = _schema!;
} else {
_headers['Content-Profile'] = _schema!;
execHeaders['Content-Profile'] = _schema!;
}
if (method != METHOD_GET && method != METHOD_HEAD) {
_headers['Content-Type'] = 'application/json';
execHeaders['Content-Type'] = 'application/json';
}
final bodyStr = jsonEncode(_body);
_log.finest("Request: $uppercaseMethod $_url");

final Future<http.Response> Function() send;
if (uppercaseMethod == METHOD_GET) {
response = await (_httpClient?.get ?? http.get)(
_url,
headers: _headers,
);
send = () => (_httpClient?.get ?? http.get)(_url, headers: execHeaders);
} else if (uppercaseMethod == METHOD_POST) {
response = await (_httpClient?.post ?? http.post)(
_url,
headers: _headers,
body: bodyStr,
);
send = () => (_httpClient?.post ?? http.post)(
_url,
headers: execHeaders,
body: bodyStr,
);
} else if (uppercaseMethod == METHOD_PUT) {
response = await (_httpClient?.put ?? http.put)(
_url,
headers: _headers,
body: bodyStr,
);
send = () => (_httpClient?.put ?? http.put)(
_url,
headers: execHeaders,
body: bodyStr,
);
} else if (uppercaseMethod == METHOD_PATCH) {
response = await (_httpClient?.patch ?? http.patch)(
_url,
headers: _headers,
body: bodyStr,
);
send = () => (_httpClient?.patch ?? http.patch)(
_url,
headers: execHeaders,
body: bodyStr,
);
} else if (uppercaseMethod == METHOD_DELETE) {
response = await (_httpClient?.delete ?? http.delete)(
_url,
headers: _headers,
);
send = () =>
(_httpClient?.delete ?? http.delete)(_url, headers: execHeaders);
} else if (uppercaseMethod == METHOD_HEAD) {
response = await (_httpClient?.head ?? http.head)(
_url,
headers: _headers,
);
send =
() => (_httpClient?.head ?? http.head)(_url, headers: execHeaders);
} else {
throw StateError('Unknown HTTP method: $uppercaseMethod');
}

final response =
await _executeWithRetry(send, uppercaseMethod, execHeaders);
return _parseResponse(response, method);
} catch (error) {
rethrow;
}
}

Future<http.Response> _executeWithRetry(
Future<http.Response> Function() send,
String method,
Map<String, String> execHeaders,
) async {
const maxRetries = 3;
const retryableStatusCodes = {520};

final effectiveRetryEnabled = _retryEnabled ?? _clientRetryEnabled;
final isRetryableMethod = method == METHOD_GET || method == METHOD_HEAD;

if (!effectiveRetryEnabled || !isRetryableMethod) {
return send();
}

for (var attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
execHeaders['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');
Comment on lines +224 to +242
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.
}

/// Parse request response to json object if possible
Future<T> _parseResponse(http.Response response, String method) async {
if (response.statusCode >= 200 && response.statusCode <= 299) {
Expand Down
6 changes: 6 additions & 0 deletions packages/postgrest/lib/src/postgrest_query_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class PostgrestQueryBuilder<T> extends RawPostgrestBuilder<T, T, T> {
String? schema,
Client? httpClient,
YAJsonIsolate? isolate,
bool clientRetryEnabled = true,
Duration Function(int attempt)? retryDelay,
}) : super(
PostgrestBuilder(
url: url,
Expand All @@ -27,6 +29,8 @@ class PostgrestQueryBuilder<T> extends RawPostgrestBuilder<T, T, T> {
schema: schema,
httpClient: httpClient,
isolate: isolate,
clientRetryEnabled: clientRetryEnabled,
retryDelay: retryDelay,
),
);

Expand Down Expand Up @@ -268,6 +272,8 @@ class PostgrestQueryBuilder<T> extends RawPostgrestBuilder<T, T, T> {
method: _method,
schema: _schema,
isolate: _isolate,
clientRetryEnabled: _clientRetryEnabled,
retryDelay: _retryDelay,
);
}
}
4 changes: 4 additions & 0 deletions packages/postgrest/lib/src/postgrest_rpc_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder {
String? schema,
Client? httpClient,
required YAJsonIsolate isolate,
bool clientRetryEnabled = true,
Duration Function(int attempt)? retryDelay,
}) : super(
PostgrestBuilder(
url: Uri.parse(url),
headers: headers ?? {},
schema: schema,
httpClient: httpClient,
isolate: isolate,
clientRetryEnabled: clientRetryEnabled,
retryDelay: retryDelay,
),
);

Expand Down
9 changes: 9 additions & 0 deletions packages/postgrest/lib/src/raw_postgrest_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class RawPostgrestBuilder<T, S, R> extends PostgrestBuilder<T, S, R> {
isolate: builder._isolate,
maybeSingle: builder._maybeSingle,
converter: builder._converter,
clientRetryEnabled: builder._clientRetryEnabled,
retryEnabled: builder._retryEnabled,
retryDelay: builder._retryDelay,
);

/// Very similar to [_copyWith], but allows changing the generics, therefore [_converter] is omitted
Expand All @@ -38,6 +41,9 @@ class RawPostgrestBuilder<T, S, R> extends PostgrestBuilder<T, S, R> {
isolate: isolate ?? _isolate,
count: count ?? _count,
maybeSingle: maybeSingle ?? _maybeSingle,
clientRetryEnabled: _clientRetryEnabled,
retryEnabled: _retryEnabled,
retryDelay: _retryDelay,
));
}

Expand Down Expand Up @@ -71,6 +77,9 @@ class RawPostgrestBuilder<T, S, R> extends PostgrestBuilder<T, S, R> {
count: _count,
maybeSingle: _maybeSingle,
converter: converter,
clientRetryEnabled: _clientRetryEnabled,
retryEnabled: _retryEnabled,
retryDelay: _retryDelay,
);
}
}
6 changes: 6 additions & 0 deletions packages/postgrest/lib/src/response_postgrest_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class ResponsePostgrestBuilder<T, S, R> extends PostgrestBuilder<T, S, R> {
isolate: builder._isolate,
maybeSingle: builder._maybeSingle,
converter: builder._converter,
clientRetryEnabled: builder._clientRetryEnabled,
retryEnabled: builder._retryEnabled,
retryDelay: builder._retryDelay,
);

@override
Expand Down Expand Up @@ -49,6 +52,9 @@ class ResponsePostgrestBuilder<T, S, R> extends PostgrestBuilder<T, S, R> {
count: _count,
maybeSingle: _maybeSingle,
converter: converter,
clientRetryEnabled: _clientRetryEnabled,
retryEnabled: _retryEnabled,
retryDelay: _retryDelay,
);
}
}
Loading
Loading