diff --git a/packages/gotrue/lib/src/gotrue_oauth_api.dart b/packages/gotrue/lib/src/gotrue_oauth_api.dart index 1fda21f34..02064ce7a 100644 --- a/packages/gotrue/lib/src/gotrue_oauth_api.dart +++ b/packages/gotrue/lib/src/gotrue_oauth_api.dart @@ -56,7 +56,7 @@ class OAuthGrant { factory OAuthGrant.fromJson(Map json) { return OAuthGrant( client: OAuthAuthorizedClient.fromJson(json['client']), - scopes: (json['scopes'] as List?)?.cast() ?? const [], + scopes: (json['scopes'] as List?)?.cast() ?? const [], grantedAt: DateTime.parse(json['granted_at'] as String), ); } diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index 9be056921..7905c331f 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -7,6 +7,9 @@ import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; /// A PostgREST api client written in Dartlang. The goal of this library is to make an "ORM-like" restful interface. class PostgrestClient { + /// HTTP status codes that trigger an automatic retry by default. + static const Set defaultRetryableStatusCodes = {503, 520}; + final String url; final Map headers; final String? _schema; @@ -14,6 +17,9 @@ class PostgrestClient { final YAJsonIsolate _isolate; final bool _hasCustomIsolate; final bool retryEnabled; + final int retryCount; + final Set retryableStatusCodes; + final Duration? requestTimeout; final Duration Function(int attempt)? _retryDelay; final _log = Logger('supabase.postgrest'); @@ -30,8 +36,22 @@ class PostgrestClient { /// [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 503, HTTP 520, or a network error. Defaults to `true`. - /// Use [PostgrestBuilder.retry] to override this per request. + /// HEAD requests that fail with a retryable status code or a network error. + /// Defaults to `true`. Use [PostgrestBuilder.retry] to override this per request. + /// + /// [retryCount] is the number of retry attempts made for a retryable request + /// before giving up. Defaults to `3`. + /// + /// [retryableStatusCodes] are the HTTP status codes that trigger a retry. + /// Defaults to `{503, 520}`. + /// + /// [requestTimeout] optionally bounds how long a single request attempt may + /// take. It is implemented on top of the abort mechanism, so it actually + /// cancels a stalled attempt instead of leaving it running. A timed-out + /// attempt is retried like any other failure, and a [TimeoutException] is + /// thrown once the retries are exhausted. When `null` (the default) no + /// timeout is applied. Use [PostgrestBuilder.abortSignal] to cancel a request + /// outright, which stops retrying immediately. PostgrestClient( this.url, { Map? headers, @@ -39,12 +59,23 @@ class PostgrestClient { this.httpClient, YAJsonIsolate? isolate, this.retryEnabled = true, + this.retryCount = 3, + Set retryableStatusCodes = defaultRetryableStatusCodes, + this.requestTimeout, @visibleForTesting Duration Function(int attempt)? retryDelay, - }) : _schema = schema, + }) : retryableStatusCodes = Set.unmodifiable(retryableStatusCodes), + _schema = schema, headers = {...defaultHeaders, ...?headers}, _isolate = isolate ?? (YAJsonIsolate()..initialize()), _hasCustomIsolate = isolate != null, _retryDelay = retryDelay { + if (retryCount < 0) { + throw ArgumentError.value( + retryCount, + 'retryCount', + 'must not be negative', + ); + } _log.config('Initialize PostgrestClient with url: $url, schema: $_schema'); _log.finest('Initialize with headers: $headers'); } @@ -76,6 +107,9 @@ class PostgrestClient { httpClient: httpClient, isolate: _isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, + requestTimeout: requestTimeout, retryDelay: _retryDelay, ); } @@ -91,6 +125,9 @@ class PostgrestClient { httpClient: httpClient, isolate: _isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, + requestTimeout: requestTimeout, retryDelay: _retryDelay, ); } @@ -123,6 +160,9 @@ class PostgrestClient { httpClient: httpClient, isolate: _isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, + requestTimeout: requestTimeout, retryDelay: _retryDelay, ).rpc(params, get); } diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index af8393874..7b689447b 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -31,6 +31,110 @@ enum HttpMethod { typedef _Nullable = T?; +/// Bundles the automatic retry configuration so it can be carried through the +/// builder chain as a single value instead of separate fields. +@immutable +class _RetryConfig { + _RetryConfig({ + this.enabled = true, + this.count = 3, + Set statusCodes = PostgrestClient.defaultRetryableStatusCodes, + Duration Function(int attempt)? delay, + }) : statusCodes = Set.unmodifiable(statusCodes), + delay = delay ?? PostgrestBuilder._defaultRetryDelay { + if (count < 0) { + throw ArgumentError.value(count, 'retryCount', 'must not be negative'); + } + } + + final bool enabled; + final int count; + final Set statusCodes; + final Duration Function(int attempt) delay; + + _RetryConfig copyWith({ + // retry() always passes enabled, but keep the standard copyWith shape. + // ignore: avoid-unnecessary-nullable-parameters + bool? enabled, + int? count, + Set? statusCodes, + Duration Function(int attempt)? delay, + }) { + return _RetryConfig( + enabled: enabled ?? this.enabled, + count: count ?? this.count, + statusCodes: statusCodes ?? this.statusCodes, + delay: delay ?? this.delay, + ); + } +} + +/// The immutable request state carried through the builder chain. +/// +/// Everything here is independent of the builder's generic types (only the +/// converter depends on them), so the typed builders can share and rewrap a +/// single config instance without re-listing its fields. +@immutable +class _RequestConfig { + const _RequestConfig({ + required this.url, + required this.headers, + this.schema, + this.method, + this.body, + this.httpClient, + this.isolate, + this.count, + this.maybeSingle = false, + required this.retry, + this.requestTimeout, + this.abortSignal, + }); + + final Uri url; + final Headers headers; + final String? schema; + final HttpMethod? method; + final Object? body; + final Client? httpClient; + final YAJsonIsolate? isolate; + final CountOption? count; + final bool maybeSingle; + final _RetryConfig retry; + final Duration? requestTimeout; + final Future? abortSignal; + + _RequestConfig copyWith({ + Uri? url, + Headers? headers, + String? schema, + HttpMethod? method, + Object? body, + Client? httpClient, + YAJsonIsolate? isolate, + CountOption? count, + bool? maybeSingle, + _RetryConfig? retry, + Duration? requestTimeout, + Future? abortSignal, + }) { + return _RequestConfig( + url: url ?? this.url, + headers: headers ?? this.headers, + schema: schema ?? this.schema, + method: method ?? this.method, + body: body ?? this.body, + httpClient: httpClient ?? this.httpClient, + isolate: isolate ?? this.isolate, + count: count ?? this.count, + maybeSingle: maybeSingle ?? this.maybeSingle, + retry: retry ?? this.retry, + requestTimeout: requestTimeout ?? this.requestTimeout, + abortSignal: abortSignal ?? this.abortSignal, + ); + } +} + /// Treats an empty `Prefer` value as absent, so every append site can rely on /// a plain null check instead of separately re-checking for emptiness. String? _emptyPreferAsNull(String? prefer) => @@ -44,21 +148,23 @@ String? _emptyPreferAsNull(String? prefer) => /// Otherwise [S] and [R] are the same @immutable class PostgrestBuilder implements Future { - final Object? _body; - final Headers _headers; - final bool _maybeSingle; - final HttpMethod? _method; - final String? _schema; - final Uri _url; + final _RequestConfig _config; final PostgrestConverter? _converter; - final Client? _httpClient; - final YAJsonIsolate? _isolate; - final CountOption? _count; - final bool _retryEnabled; - final Duration Function(int attempt) _retryDelay; - final Future? _abortSignal; final _log = Logger('supabase.postgrest'); + Object? get _body => _config.body; + Headers get _headers => _config.headers; + bool get _maybeSingle => _config.maybeSingle; + HttpMethod? get _method => _config.method; + String? get _schema => _config.schema; + Uri get _url => _config.url; + Client? get _httpClient => _config.httpClient; + YAJsonIsolate? get _isolate => _config.isolate; + CountOption? get _count => _config.count; + _RetryConfig get _retry => _config.retry; + Duration? get _requestTimeout => _config.requestTimeout; + Future? get _abortSignal => _config.abortSignal; + static Duration _defaultRetryDelay(int attempt) => Duration(seconds: math.min(math.pow(2, attempt).toInt(), 30)); @@ -74,21 +180,40 @@ class PostgrestBuilder implements Future { bool maybeSingle = false, PostgrestConverter? converter, bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, @visibleForTesting Duration Function(int attempt)? retryDelay, + Duration? requestTimeout, Future? abortSignal, - }) : _maybeSingle = maybeSingle, - _method = method, - _converter = converter, - _schema = schema, - _url = url, - _headers = headers, - _httpClient = httpClient, - _isolate = isolate, - _count = count, - _body = body, - _retryEnabled = retryEnabled, - _retryDelay = retryDelay ?? _defaultRetryDelay, - _abortSignal = abortSignal; + }) : _converter = converter, + _config = _RequestConfig( + url: url, + headers: headers, + schema: schema, + method: method, + body: body, + httpClient: httpClient, + isolate: isolate, + count: count, + maybeSingle: maybeSingle, + retry: _RetryConfig( + enabled: retryEnabled, + count: retryCount, + statusCodes: retryableStatusCodes, + delay: retryDelay, + ), + requestTimeout: requestTimeout, + abortSignal: abortSignal, + ); + + /// Rewraps an existing [config] under a possibly different [converter] (and + /// therefore possibly different generic types). This is what lets the typed + /// builders share a single config instance without re-listing its fields. + PostgrestBuilder._({ + required _RequestConfig config, + required PostgrestConverter? converter, + }) : _config = config, + _converter = converter; PostgrestBuilder _copyWith({ Uri? url, @@ -101,26 +226,26 @@ class PostgrestBuilder implements Future { CountOption? count, bool? maybeSingle, PostgrestConverter? converter, - bool? retryEnabled, - Duration Function(int attempt)? retryDelay, + _RetryConfig? retry, + Duration? requestTimeout, Future? abortSignal, - }) { - return PostgrestBuilder( - url: url ?? _url, - headers: headers ?? _headers, - schema: schema ?? _schema, - method: method ?? _method, - body: body ?? _body, - httpClient: httpClient ?? _httpClient, - isolate: isolate ?? _isolate, - count: count ?? _count, - maybeSingle: maybeSingle ?? _maybeSingle, - converter: converter ?? _converter, - retryEnabled: retryEnabled ?? _retryEnabled, - retryDelay: retryDelay ?? _retryDelay, - abortSignal: abortSignal ?? _abortSignal, - ); - } + }) => PostgrestBuilder._( + config: _config.copyWith( + url: url, + headers: headers, + schema: schema, + method: method, + body: body, + httpClient: httpClient, + isolate: isolate, + count: count, + maybeSingle: maybeSingle, + retry: retry, + requestTimeout: requestTimeout, + abortSignal: abortSignal, + ), + converter: converter ?? _converter, + ); /// Overrides the retry behavior for this specific request. /// @@ -128,8 +253,19 @@ class PostgrestBuilder implements Future { /// [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 retry({required bool enabled}) => - _copyWith(retryEnabled: enabled); + /// + /// [count] overrides the number of retry attempts for this request. + /// + /// [requestTimeout] overrides the per-attempt timeout for this request. When + /// `null`, the timeout configured on [PostgrestClient] is kept. + PostgrestBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) => _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ); /// Allows manually triggering request abortion by completing the provided /// [Future]. @@ -188,11 +324,12 @@ class PostgrestBuilder implements Future { // X-Retry-Count, etc.). final execHeaders = {..._headers}; - if (_count != null) { + final count = _count; + if (count != null) { final oldPreferHeader = _emptyPreferAsNull(execHeaders['Prefer']); execHeaders['Prefer'] = oldPreferHeader != null - ? '$oldPreferHeader,count=${_count.name}' - : 'count=${_count.name}'; + ? '$oldPreferHeader,count=${count.name}' + : 'count=${count.name}'; } if (method == null) { @@ -201,12 +338,13 @@ class PostgrestBuilder implements Future { ); } - if (_schema == null) { + final schema = _schema; + if (schema == null) { // skip } else if (method == HttpMethod.get || method == HttpMethod.head) { - execHeaders['Accept-Profile'] = _schema; + execHeaders['Accept-Profile'] = schema; } else { - execHeaders['Content-Profile'] = _schema; + execHeaders['Content-Profile'] = schema; } if (method != HttpMethod.get && method != HttpMethod.head) { execHeaders['Content-Type'] = 'application/json'; @@ -214,12 +352,36 @@ class PostgrestBuilder implements Future { final bodyStr = jsonEncode(_body); _log.finest("Request: ${method.value} $_url"); - final Future Function() send; - send = () async { + final requestTimeout = _requestTimeout; + + Future send() async { + // The request timeout bounds each individual attempt. It is implemented + // on top of the abort mechanism so it actually cancels a stalled attempt + // instead of leaving it running. A timed-out attempt surfaces as a + // [TimeoutException] so the retry loop treats it as a retryable failure, + // whereas the caller-provided [_abortSignal] keeps its + // [RequestAbortedException] and stops retries outright. + var timedOut = false; + Timer? timeoutTimer; + Future? abortTrigger = _abortSignal; + if (requestTimeout != null) { + final timeoutCompleter = Completer(); + timeoutTimer = Timer(requestTimeout, () { + timedOut = true; + if (!timeoutCompleter.isCompleted) { + timeoutCompleter.complete(); + } + }); + final abortSignal = _abortSignal; + abortTrigger = abortSignal == null + ? timeoutCompleter.future + : Future.any([abortSignal, timeoutCompleter.future]); + } + final AbortableRequest request = AbortableRequest( method.value, _url, - abortTrigger: _abortSignal, + abortTrigger: abortTrigger, ); request.headers.addAll(execHeaders); switch (method) { @@ -233,12 +395,18 @@ class PostgrestBuilder implements Future { try { final streamResponse = await client.send(request); return await http.Response.fromStream(streamResponse); + } on RequestAbortedException { + if (timedOut) { + throw TimeoutException('Request timed out', requestTimeout); + } + rethrow; } finally { + timeoutTimer?.cancel(); if (_httpClient == null) { client.close(); } } - }; + } final response = await _executeWithRetry(send, method, execHeaders); return await _parseResponse(response, method); @@ -249,13 +417,13 @@ class PostgrestBuilder implements Future { HttpMethod method, Map execHeaders, ) async { - const maxRetries = 3; - const retryableStatusCodes = {503, 520}; + final maxRetries = _retry.count; + final retryableStatusCodes = _retry.statusCodes; final isRetryableMethod = method == HttpMethod.get || method == HttpMethod.head; - if (!_retryEnabled || !isRetryableMethod) { + if (!_retry.enabled || !isRetryableMethod) { return send(); } @@ -271,12 +439,15 @@ class PostgrestBuilder implements Future { return response; } } on RequestAbortedException catch (_) { + // A manual abort stops retrying immediately. A per-attempt timeout is + // surfaced as a TimeoutException instead, so it falls through to the + // retryable branch below. rethrow; } on Exception { if (attempt == maxRetries) rethrow; } - await Future.delayed(_retryDelay(attempt)); + await Future.delayed(_retry.delay(attempt)); } throw StateError('unreachable'); @@ -298,8 +469,9 @@ class PostgrestBuilder implements Future { body = response.body; } else { try { - if ((response.contentLength ?? 0) > 10000 && _isolate != null) { - body = await _isolate.decode(response.body); + final isolate = _isolate; + if ((response.contentLength ?? 0) > 10000 && isolate != null) { + body = await isolate.decode(response.body); } else { body = jsonDecode(response.body); } @@ -419,8 +591,8 @@ class PostgrestBuilder implements Future { http.Response response, PostgrestException error, ) { - if (error.details case final String details - when details.contains('Results contain 0 rows')) { + if (error.details is String && + (error.details as String).contains('Results contain 0 rows')) { if (_count != null && response.request!.method != HttpMethod.head.value) { if (_converter != null) { return PostgrestResponse(data: _converter(null as R), count: 0) diff --git a/packages/postgrest/lib/src/postgrest_filter_builder.dart b/packages/postgrest/lib/src/postgrest_filter_builder.dart index 047ab3aca..6c922b0a5 100644 --- a/packages/postgrest/lib/src/postgrest_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_filter_builder.dart @@ -523,8 +523,17 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { } @override - PostgrestFilterBuilder retry({required bool enabled}) { - return PostgrestFilterBuilder(_copyWith(retryEnabled: enabled)); + PostgrestFilterBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) { + return PostgrestFilterBuilder( + _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ), + ); } @override diff --git a/packages/postgrest/lib/src/postgrest_query_builder.dart b/packages/postgrest/lib/src/postgrest_query_builder.dart index 571f09b5f..b8019f0f8 100644 --- a/packages/postgrest/lib/src/postgrest_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_query_builder.dart @@ -20,7 +20,11 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { Client? httpClient, YAJsonIsolate? isolate, bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, Duration Function(int attempt)? retryDelay, + Duration? requestTimeout, + Future? abortSignal, }) : super( PostgrestBuilder( url: url, @@ -30,10 +34,16 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { httpClient: httpClient, isolate: isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, retryDelay: retryDelay, + requestTimeout: requestTimeout, + abortSignal: abortSignal, ), ); + PostgrestQueryBuilder._(super.builder); + /// Perform a SELECT query on the table or view. /// /// ```dart @@ -273,16 +283,16 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { } @override - PostgrestQueryBuilder retry({required bool enabled}) { - return PostgrestQueryBuilder( - url: _url, - headers: _headers, - httpClient: _httpClient, - method: _method, - schema: _schema, - isolate: _isolate, - retryEnabled: enabled, - retryDelay: _retryDelay, + PostgrestQueryBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) { + return PostgrestQueryBuilder._( + _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ), ); } @@ -295,8 +305,12 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { method: _method, schema: _schema, isolate: _isolate, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, + retryEnabled: _retry.enabled, + retryCount: _retry.count, + retryableStatusCodes: _retry.statusCodes, + retryDelay: _retry.delay, + requestTimeout: _requestTimeout, + abortSignal: _abortSignal, ); } } diff --git a/packages/postgrest/lib/src/postgrest_rpc_builder.dart b/packages/postgrest/lib/src/postgrest_rpc_builder.dart index 40d31a6df..99b4006ba 100644 --- a/packages/postgrest/lib/src/postgrest_rpc_builder.dart +++ b/packages/postgrest/lib/src/postgrest_rpc_builder.dart @@ -9,7 +9,11 @@ class PostgrestRpcBuilder Client? httpClient, required YAJsonIsolate isolate, bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, Duration Function(int attempt)? retryDelay, + Duration? requestTimeout, + Future? abortSignal, }) : super( PostgrestBuilder( url: Uri.parse(url), @@ -18,7 +22,11 @@ class PostgrestRpcBuilder httpClient: httpClient, isolate: isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, retryDelay: retryDelay, + requestTimeout: requestTimeout, + abortSignal: abortSignal, ), ); diff --git a/packages/postgrest/lib/src/postgrest_transform_builder.dart b/packages/postgrest/lib/src/postgrest_transform_builder.dart index f4f0f30ba..2c6c64553 100644 --- a/packages/postgrest/lib/src/postgrest_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_transform_builder.dart @@ -7,8 +7,17 @@ class PostgrestTransformBuilder extends RawPostgrestBuilder { PostgrestTransformBuilder(_copyWith(url: url)); @override - PostgrestTransformBuilder retry({required bool enabled}) { - return PostgrestTransformBuilder(_copyWith(retryEnabled: enabled)); + PostgrestTransformBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) { + return PostgrestTransformBuilder( + _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ), + ); } @override diff --git a/packages/postgrest/lib/src/raw_postgrest_builder.dart b/packages/postgrest/lib/src/raw_postgrest_builder.dart index 21df6783f..8d64003ca 100644 --- a/packages/postgrest/lib/src/raw_postgrest_builder.dart +++ b/packages/postgrest/lib/src/raw_postgrest_builder.dart @@ -3,20 +3,7 @@ part of 'postgrest_builder.dart'; /// Needed as a wrapper around [PostgrestBuilder] to allow for the different return type of [withConverter] than in [ResponsePostgrestBuilder.withConverter]. class RawPostgrestBuilder extends PostgrestBuilder { RawPostgrestBuilder(PostgrestBuilder builder) - : super( - url: builder._url, - method: builder._method, - headers: builder._headers, - schema: builder._schema, - body: builder._body, - httpClient: builder._httpClient, - count: builder._count, - isolate: builder._isolate, - maybeSingle: builder._maybeSingle, - converter: builder._converter, - retryEnabled: builder._retryEnabled, - retryDelay: builder._retryDelay, - ); + : super._(config: builder._config, converter: builder._converter); /// Very similar to [_copyWith], but allows changing the generics, therefore [_converter] is omitted RawPostgrestBuilder _copyWithType({ @@ -32,18 +19,19 @@ class RawPostgrestBuilder extends PostgrestBuilder { bool? maybeSingle, }) { return RawPostgrestBuilder( - PostgrestBuilder( - url: url ?? _url, - headers: headers ?? _headers, - schema: schema ?? _schema, - method: method ?? _method, - body: body ?? _body, - httpClient: httpClient ?? _httpClient, - isolate: isolate ?? _isolate, - count: count ?? _count, - maybeSingle: maybeSingle ?? _maybeSingle, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, + PostgrestBuilder._( + config: _config.copyWith( + url: url, + headers: headers, + schema: schema, + method: method, + body: body, + httpClient: httpClient, + isolate: isolate, + count: count, + maybeSingle: maybeSingle, + ), + converter: null, ), ); } @@ -68,19 +56,6 @@ class RawPostgrestBuilder extends PostgrestBuilder { PostgrestBuilder withConverter( PostgrestConverter converter, ) { - return PostgrestBuilder( - url: _url, - headers: _headers, - schema: _schema, - method: _method, - body: _body, - isolate: _isolate, - httpClient: _httpClient, - count: _count, - maybeSingle: _maybeSingle, - converter: converter, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, - ); + return PostgrestBuilder._(config: _config, converter: converter); } } diff --git a/packages/postgrest/lib/src/response_postgrest_builder.dart b/packages/postgrest/lib/src/response_postgrest_builder.dart index e0d03eb10..88deaa18f 100644 --- a/packages/postgrest/lib/src/response_postgrest_builder.dart +++ b/packages/postgrest/lib/src/response_postgrest_builder.dart @@ -3,20 +3,7 @@ part of 'postgrest_builder.dart'; /// Needed as a wrapper around [PostgrestBuilder] to allow for the different return type of [withConverter] than in [RawPostgrestBuilder.withConverter]. class ResponsePostgrestBuilder extends PostgrestBuilder { ResponsePostgrestBuilder(PostgrestBuilder builder) - : super( - url: builder._url, - method: builder._method, - headers: builder._headers, - schema: builder._schema, - body: builder._body, - httpClient: builder._httpClient, - count: builder._count, - isolate: builder._isolate, - maybeSingle: builder._maybeSingle, - converter: builder._converter, - retryEnabled: builder._retryEnabled, - retryDelay: builder._retryDelay, - ); + : super._(config: builder._config, converter: builder._converter); @override ResponsePostgrestBuilder setHeader(String key, String value) { @@ -41,19 +28,6 @@ class ResponsePostgrestBuilder extends PostgrestBuilder { PostgrestBuilder, U, R> withConverter( PostgrestConverter converter, ) { - return PostgrestBuilder( - url: _url, - headers: _headers, - schema: _schema, - method: _method, - body: _body, - isolate: _isolate, - httpClient: _httpClient, - count: _count, - maybeSingle: _maybeSingle, - converter: converter, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, - ); + return PostgrestBuilder._(config: _config, converter: converter); } } diff --git a/packages/postgrest/test/retry_test.dart b/packages/postgrest/test/retry_test.dart index 8fe116974..25649a559 100644 --- a/packages/postgrest/test/retry_test.dart +++ b/packages/postgrest/test/retry_test.dart @@ -38,9 +38,14 @@ _ResponseFactory _networkError() => class _MockRetryClient extends BaseClient { final List<_ResponseFactory> _responses; + final Duration Function(int index) _responseLatency; final List requests = []; - _MockRetryClient(this._responses); + _MockRetryClient( + this._responses, { + Duration Function(int index)? responseLatency, + }) : _responseLatency = + responseLatency ?? ((_) => const Duration(milliseconds: 200)); int get callCount => requests.length; @@ -68,7 +73,7 @@ class _MockRetryClient extends BaseClient { ); } unawaited( - Future.delayed(Duration(milliseconds: 200)).then((_) { + Future.delayed(_responseLatency(index)).then((_) { if (!completer.isCompleted) { completer.complete(_responses[index](request)); } @@ -81,12 +86,16 @@ class _MockRetryClient extends BaseClient { PostgrestClient _buildClient( _MockRetryClient mock, { bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = const {503, 520}, }) { return PostgrestClient( 'http://localhost:3000', httpClient: mock, retryEnabled: retryEnabled, - retryDelay: (_) => Duration(milliseconds: 200), + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, + retryDelay: (_) => Duration.zero, ); } @@ -286,4 +295,222 @@ void main() { }, ); }); + + group('configurable retry count', () { + test('client retryCount limits the number of retries', () async { + final mock = _MockRetryClient([ + _status(520), + _status(520), + _status(520), + ]); + final client = _buildClient(mock, retryCount: 1); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + // Initial attempt + 1 retry. + expect(mock.callCount, 2); + }); + + test('retryCount: 0 disables retries', () async { + final mock = _MockRetryClient([_status(520)]); + final client = _buildClient(mock, retryCount: 0); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('.retry(count:) overrides the retry count per request', () async { + final mock = _MockRetryClient([_status(520), _status(520), _ok()]); + final client = _buildClient(mock, retryCount: 1); + + final result = await client.from('users').select().retry(count: 5); + + expect(result, isEmpty); + expect(mock.callCount, 3); + }); + + test('negative retryCount throws ArgumentError', () { + expect( + () => PostgrestClient('http://localhost:3000', retryCount: -1), + throwsA(isA()), + ); + }); + }); + + group('configurable retryable status codes', () { + test('retries on a custom status code', () async { + final mock = _MockRetryClient([_status(500), _ok()]); + final client = _buildClient(mock, retryableStatusCodes: {500}); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + }); + + test('does not retry on a status code outside the custom set', () async { + final mock = _MockRetryClient([_status(520)]); + final client = _buildClient(mock, retryableStatusCodes: {500}); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('mutating the provided set does not affect retry behavior', () async { + final mock = _MockRetryClient([_status(500), _ok()]); + final statusCodes = {500}; + final client = _buildClient(mock, retryableStatusCodes: statusCodes); + + statusCodes.clear(); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + }); + }); + + group('retry config propagation through builder chain', () { + test( + 'count() preserves custom retryCount and retryableStatusCodes', + () async { + _ResponseFactory okWithCount() => + (req) => Future.value( + StreamedResponse( + Stream.value(Uint8List.fromList('[]'.codeUnits)), + 200, + request: req, + headers: { + 'content-type': 'application/json', + 'content-range': '0-0/0', + }, + ), + ); + final mock = _MockRetryClient([ + _status(500), + _status(500), + okWithCount(), + ]); + final client = _buildClient( + mock, + retryCount: 5, + retryableStatusCodes: {500}, + ); + + await client.from('users').select().count(CountOption.exact); + + expect(mock.callCount, 3); + }, + ); + }); + + group('request timeout', () { + test('a timed-out attempt is retried, not hard-stopped', () async { + // Every attempt takes 200ms while the timeout is 50ms, so each attempt + // times out and is retried until the retries are exhausted. + final mock = _MockRetryClient([_ok(), _ok(), _ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + retryCount: 2, + requestTimeout: const Duration(milliseconds: 50), + retryDelay: (_) => Duration.zero, + ); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + // Initial attempt plus 2 retries, so the timeout did not stop retrying. + expect(mock.callCount, 3); + }); + + test( + 'retries recover once an attempt completes within the timeout', + () async { + // First attempt is slower than the timeout, the second is fast. + final mock = _MockRetryClient( + [_ok(), _ok()], + responseLatency: (index) => + index == 0 ? const Duration(milliseconds: 300) : Duration.zero, + ); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + requestTimeout: const Duration(milliseconds: 100), + retryDelay: (_) => Duration.zero, + ); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + }, + ); + + test('does not time out a request that completes in time', () async { + final mock = _MockRetryClient([_ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + requestTimeout: const Duration(seconds: 5), + ); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 1); + }); + + test('.retry(requestTimeout:) overrides the timeout per request', () async { + // The client has no timeout, but the per-request override adds one that + // is shorter than every attempt, so each attempt times out and is retried. + final mock = _MockRetryClient([_ok(), _ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + retryCount: 1, + retryDelay: (_) => Duration.zero, + ); + + await expectLater( + () => client + .from('users') + .select() + .retry(requestTimeout: const Duration(milliseconds: 50)), + throwsA(isA()), + ); + // Initial attempt plus 1 retry. + expect(mock.callCount, 2); + }); + + test('a manual abortSignal stops retrying immediately', () async { + final mock = _MockRetryClient([_status(520), _status(520), _ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + requestTimeout: const Duration(seconds: 5), + retryDelay: (_) => Duration.zero, + ); + + final abort = Completer(); + // Abort during the second attempt. + Timer(const Duration(milliseconds: 300), abort.complete); + + await expectLater( + () => client.from('users').select().abortSignal(abort.future), + throwsA(isA()), + ); + // Stopped mid-operation instead of exhausting all retries. + expect(mock.callCount, 2); + }); + }); } diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 7cad13681..5b8795df2 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -325,6 +325,10 @@ class SupabaseClient { schema: _postgrestOptions.schema, httpClient: _authHttpClient, isolate: _isolate, + retryEnabled: _postgrestOptions.retryEnabled, + retryCount: _postgrestOptions.retryCount, + retryableStatusCodes: _postgrestOptions.retryableStatusCodes, + requestTimeout: _postgrestOptions.requestTimeout, ); } diff --git a/packages/supabase/lib/src/supabase_client_options.dart b/packages/supabase/lib/src/supabase_client_options.dart index c8bcf2607..36b025c37 100644 --- a/packages/supabase/lib/src/supabase_client_options.dart +++ b/packages/supabase/lib/src/supabase_client_options.dart @@ -3,7 +3,32 @@ import 'package:supabase/supabase.dart'; class PostgrestClientOptions { final String schema; - const PostgrestClientOptions({this.schema = 'public'}); + /// Whether automatic retries are performed for GET and HEAD requests that + /// fail with a retryable status code or a network error. + final bool retryEnabled; + + /// The number of retry attempts made for a retryable request before giving up. + final int retryCount; + + /// The HTTP status codes that trigger an automatic retry. + final Set retryableStatusCodes; + + /// Bounds how long a single request attempt may take. + /// + /// Implemented on top of the abort mechanism, so it actually cancels a + /// stalled attempt instead of leaving it running. A timed-out attempt is + /// retried like any other failure, and a `TimeoutException` is thrown once + /// the retries are exhausted. When `null` (the default) no timeout is + /// applied. + final Duration? requestTimeout; + + const PostgrestClientOptions({ + this.schema = 'public', + this.retryEnabled = true, + this.retryCount = 3, + this.retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, + this.requestTimeout, + }); } class AuthClientOptions { diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 0223aa4b9..b10d258a2 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -366,11 +366,21 @@ features: # database — configuration database.configuration.auto_retry: - status: partially_implemented - note: "Retries GET/HEAD requests on transient failures with backoff, but the retry count and conditions are hard-coded, not configurable." + status: implemented + symbols: + - PostgrestClient.retryEnabled + - PostgrestClient.retryCount + - PostgrestClient.retryableStatusCodes + - PostgrestClientOptions.retryEnabled + - PostgrestClientOptions.retryCount + - PostgrestClientOptions.retryableStatusCodes + - PostgrestBuilder.retry + - PostgrestClient.defaultRetryableStatusCodes database.configuration.request_timeout: - status: not_applicable - note: "Requires cancelling in-flight requests at the deadline, but the http.Client transport has no cancellation primitive. The only Dart-native option is Future.timeout, which lets the request keep running and is functionally identical to a caller applying .timeout() themselves, so it adds no real capability. Same reasoning as functions.invocation.timeout; a genuine version would require database.using_modifiers.request_cancellation." + status: implemented + symbols: + - PostgrestClient.requestTimeout + - PostgrestClientOptions.requestTimeout # storage — file buckets storage.file_buckets.access_bucket: implemented