diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index 6a531a4f2..31bc5f6ac 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -15,6 +15,15 @@ class PostgrestClient { final bool _hasCustomIsolate; final bool retryEnabled; final Duration Function(int attempt)? _retryDelay; + + /// Optional timeout in milliseconds for PostgREST requests. When set, + /// requests automatically abort after this duration to prevent indefinite hangs. + final int? timeout; + + /// Maximum URL length in characters before a warning is logged. Defaults to 8000. + /// Protects against exceeding server URL limits with large queries. + final int urlLengthLimit; + final _log = Logger('supabase.postgrest'); /// To create a [PostgrestClient], you need to provide an [url] endpoint. @@ -32,6 +41,10 @@ class PostgrestClient { /// [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. + /// + /// [timeout] is optional and can be used to set a timeout in milliseconds for requests + /// + /// [urlLengthLimit] is optional and can be used to set the maximum URL length before a warning is logged. Defaults to 8000. PostgrestClient( this.url, { Map? headers, @@ -40,6 +53,8 @@ class PostgrestClient { YAJsonIsolate? isolate, this.retryEnabled = true, @visibleForTesting Duration Function(int attempt)? retryDelay, + this.timeout, + this.urlLengthLimit = 8000, }) : _schema = schema, headers = {...defaultHeaders, if (headers != null) ...headers}, _isolate = isolate ?? (YAJsonIsolate()..initialize()), @@ -77,6 +92,8 @@ class PostgrestClient { isolate: _isolate, retryEnabled: retryEnabled, retryDelay: _retryDelay, + timeout: timeout, + urlLengthLimit: urlLengthLimit, ); } @@ -92,6 +109,8 @@ class PostgrestClient { isolate: _isolate, retryEnabled: retryEnabled, retryDelay: _retryDelay, + timeout: timeout, + urlLengthLimit: urlLengthLimit, ); } @@ -124,6 +143,8 @@ class PostgrestClient { isolate: _isolate, retryEnabled: retryEnabled, retryDelay: _retryDelay, + timeout: timeout, + urlLengthLimit: urlLengthLimit, ).rpc(params, get); } diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index 89aa158db..cbcb023ab 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -50,6 +50,14 @@ class PostgrestBuilder implements Future { final CountOption? _count; final bool _retryEnabled; final Duration Function(int attempt) _retryDelay; + + /// Optional timeout in milliseconds for this request. When set, the request + /// automatically aborts after this duration to prevent indefinite hangs. + final int? _timeout; + + /// Maximum URL length in characters before a warning is logged. Defaults to 8000. + final int _urlLengthLimit; + final _log = Logger('supabase.postgrest'); static Duration _defaultRetryDelay(int attempt) => @@ -69,6 +77,8 @@ class PostgrestBuilder implements Future { PostgrestConverter? converter, bool retryEnabled = true, @visibleForTesting Duration Function(int attempt)? retryDelay, + int? timeout, + int urlLengthLimit = 8000, }) : _maybeSingle = maybeSingle, _method = method, _converter = converter, @@ -80,7 +90,9 @@ class PostgrestBuilder implements Future { _count = count, _body = body, _retryEnabled = retryEnabled, - _retryDelay = retryDelay ?? _defaultRetryDelay; + _retryDelay = retryDelay ?? _defaultRetryDelay, + _timeout = timeout, + _urlLengthLimit = urlLengthLimit; PostgrestBuilder _copyWith({ Uri? url, @@ -109,6 +121,8 @@ class PostgrestBuilder implements Future { converter: converter ?? _converter, retryEnabled: retryEnabled ?? _retryEnabled, retryDelay: retryDelay ?? _retryDelay, + timeout: _timeout, + urlLengthLimit: _urlLengthLimit, ); } @@ -134,15 +148,25 @@ class PostgrestBuilder implements Future { // X-Retry-Count, etc.). final execHeaders = {..._headers}; - if (_count != null) { + final count = _count; + if (count != null) { if (execHeaders['Prefer'] != null) { final oldPreferHeader = execHeaders['Prefer']; - execHeaders['Prefer'] = '$oldPreferHeader,count=${_count.name}'; + execHeaders['Prefer'] = '$oldPreferHeader,count=${count.name}'; } else { - execHeaders['Prefer'] = 'count=${_count.name}'; + execHeaders['Prefer'] = 'count=${count.name}'; } } + final urlLength = _url.toString().length; + if (urlLength > _urlLengthLimit) { + _log.warning( + 'Request URL is $urlLength characters, which exceeds the limit of $_urlLengthLimit. ' + 'If selecting many fields, consider using a view. ' + 'If filtering with large arrays, consider using an RPC function.', + ); + } + try { if (method == null) { throw ArgumentError( @@ -150,12 +174,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'; @@ -213,7 +238,11 @@ class PostgrestBuilder implements Future { method == _HttpMethod.get || method == _HttpMethod.head; if (!_retryEnabled || !isRetryableMethod) { - return send(); + final responseFuture = send(); + if (_timeout != null) { + return responseFuture.timeout(Duration(milliseconds: _timeout)); + } + return responseFuture; } for (var attempt = 0; attempt <= maxRetries; attempt++) { @@ -222,7 +251,10 @@ class PostgrestBuilder implements Future { } try { - final response = await send(); + final responseFuture = send(); + final response = _timeout != null + ? await responseFuture.timeout(Duration(milliseconds: _timeout)) + : await responseFuture; if (!retryableStatusCodes.contains(response.statusCode) || attempt == maxRetries) { return response; @@ -253,8 +285,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); } @@ -308,16 +341,17 @@ class PostgrestBuilder implements Future { } body as R; - if (_converter != null) { - converted = _converter(body); + final converter = _converter; + if (converter != null) { + converted = converter(body); } else { converted = body as S; } - if (_count != null && method != _HttpMethod.head) { + if (count != null && method != _HttpMethod.head) { return PostgrestResponse( data: converted, - count: count!, + count: count, ) as T; } else { return converted as T; @@ -369,17 +403,18 @@ class PostgrestBuilder implements Future { ) { if (error.details is String && error.details.toString().contains('Results contain 0 rows')) { + final converter = _converter; if (_count != null && response.request!.method != _HttpMethod.head.value) { - if (_converter != null) { - return PostgrestResponse(data: _converter(null as R), count: 0) + if (converter != null) { + return PostgrestResponse(data: converter(null as R), count: 0) as T; } else { return null as T; } } else { - if (_converter != null) { - return _converter(null as R) as T; + if (converter != null) { + return converter(null as R) as T; } else { return null as T; } diff --git a/packages/postgrest/lib/src/postgrest_query_builder.dart b/packages/postgrest/lib/src/postgrest_query_builder.dart index f15a9a6fd..fe1bba6d2 100644 --- a/packages/postgrest/lib/src/postgrest_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_query_builder.dart @@ -22,6 +22,8 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { YAJsonIsolate? isolate, bool retryEnabled = true, Duration Function(int attempt)? retryDelay, + int? timeout, + int urlLengthLimit = 8000, }) : super( PostgrestBuilder( url: url, @@ -32,6 +34,8 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { isolate: isolate, retryEnabled: retryEnabled, retryDelay: retryDelay, + timeout: timeout, + urlLengthLimit: urlLengthLimit, ), ); @@ -275,6 +279,8 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { isolate: _isolate, retryEnabled: enabled, retryDelay: _retryDelay, + timeout: _timeout, + urlLengthLimit: _urlLengthLimit, ); } @@ -289,6 +295,8 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { isolate: _isolate, retryEnabled: _retryEnabled, retryDelay: _retryDelay, + timeout: _timeout, + urlLengthLimit: _urlLengthLimit, ); } } diff --git a/packages/postgrest/lib/src/postgrest_rpc_builder.dart b/packages/postgrest/lib/src/postgrest_rpc_builder.dart index 123fdccc6..a6219b465 100644 --- a/packages/postgrest/lib/src/postgrest_rpc_builder.dart +++ b/packages/postgrest/lib/src/postgrest_rpc_builder.dart @@ -9,6 +9,8 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder { required YAJsonIsolate isolate, bool retryEnabled = true, Duration Function(int attempt)? retryDelay, + int? timeout, + int urlLengthLimit = 8000, }) : super( PostgrestBuilder( url: Uri.parse(url), @@ -18,6 +20,8 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder { isolate: isolate, retryEnabled: retryEnabled, retryDelay: retryDelay, + timeout: timeout, + urlLengthLimit: urlLengthLimit, ), ); diff --git a/packages/postgrest/lib/src/raw_postgrest_builder.dart b/packages/postgrest/lib/src/raw_postgrest_builder.dart index 2f6bb7f24..73aa8264a 100644 --- a/packages/postgrest/lib/src/raw_postgrest_builder.dart +++ b/packages/postgrest/lib/src/raw_postgrest_builder.dart @@ -16,6 +16,8 @@ class RawPostgrestBuilder extends PostgrestBuilder { converter: builder._converter, retryEnabled: builder._retryEnabled, retryDelay: builder._retryDelay, + timeout: builder._timeout, + urlLengthLimit: builder._urlLengthLimit, ); /// Very similar to [_copyWith], but allows changing the generics, therefore [_converter] is omitted @@ -42,6 +44,8 @@ class RawPostgrestBuilder extends PostgrestBuilder { maybeSingle: maybeSingle ?? _maybeSingle, retryEnabled: _retryEnabled, retryDelay: _retryDelay, + timeout: _timeout, + urlLengthLimit: _urlLengthLimit, )); } @@ -77,6 +81,8 @@ class RawPostgrestBuilder extends PostgrestBuilder { converter: converter, retryEnabled: _retryEnabled, retryDelay: _retryDelay, + timeout: _timeout, + urlLengthLimit: _urlLengthLimit, ); } } diff --git a/packages/postgrest/test/timeout_url_length_test.dart b/packages/postgrest/test/timeout_url_length_test.dart new file mode 100644 index 000000000..4ba54b3ff --- /dev/null +++ b/packages/postgrest/test/timeout_url_length_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +class _DelayedHttpClient extends BaseClient { + final Duration delay; + + _DelayedHttpClient({required this.delay}); + + @override + Future send(BaseRequest request) async { + await Future.delayed(delay); + return StreamedResponse( + Stream.value(Uint8List.fromList('[]'.codeUnits)), + 200, + request: request, + ); + } +} + +class _InstantHttpClient extends BaseClient { + final String body; + + _InstantHttpClient({this.body = '[]'}); + + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(Uint8List.fromList(body.codeUnits)), + 200, + request: request, + ); + } +} + +void main() { + group('URL length validation', () { + test('logs warning when URL exceeds urlLengthLimit', () async { + final warnings = []; + final subscription = Logger.root.onRecord.listen((record) { + if (record.level == Level.WARNING) { + warnings.add(record.message); + } + }); + Logger.root.level = Level.ALL; + + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: _InstantHttpClient(), + urlLengthLimit: 50, + ); + + // URL: http://localhost:3000/users?select=id,username,email,created_at — well over 50 chars + await client + .from('users') + .select('id,username,email,created_at') + .catchError((_) => []); + + await subscription.cancel(); + + expect( + warnings.any((w) => w.contains('exceeds the limit of 50')), + isTrue, + reason: 'Expected a warning about URL length', + ); + }); + + test('does not log warning when URL is within urlLengthLimit', () async { + final warnings = []; + final subscription = Logger.root.onRecord.listen((record) { + if (record.level == Level.WARNING && + record.message.contains('exceeds the limit')) { + warnings.add(record.message); + } + }); + Logger.root.level = Level.ALL; + + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: _InstantHttpClient(), + urlLengthLimit: 8000, + ); + + await client.from('users').select().catchError((_) => []); + + await subscription.cancel(); + + expect(warnings, isEmpty, reason: 'Expected no URL length warning'); + }); + + test('default urlLengthLimit is 8000', () { + final client = PostgrestClient('http://localhost:3000'); + expect(client.urlLengthLimit, equals(8000)); + }); + + test('custom urlLengthLimit is stored', () { + final client = + PostgrestClient('http://localhost:3000', urlLengthLimit: 5000); + expect(client.urlLengthLimit, equals(5000)); + }); + }); + + group('Timeout configuration', () { + test('timeout is null by default', () { + final client = PostgrestClient('http://localhost:3000'); + expect(client.timeout, isNull); + }); + + test('custom timeout is stored', () { + final client = PostgrestClient('http://localhost:3000', timeout: 5000); + expect(client.timeout, equals(5000)); + }); + + test('request times out when response is delayed beyond timeout', () async { + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: _DelayedHttpClient(delay: const Duration(seconds: 5)), + timeout: 100, // 100ms timeout + ); + + expect( + () => client.from('users').select(), + throwsA(isA()), + ); + }); + + test('request succeeds when response is within timeout', () async { + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: _InstantHttpClient(body: '[{"id": 1}]'), + timeout: 5000, // 5 second timeout + ); + + final result = await client.from('users').select(); + expect(result, isA()); + }); + }); +} diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 8a30b1206..4dfad8075 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -186,6 +186,8 @@ class SupabaseClient { httpClient: _authHttpClient, incrementId: _incrementId.increment(), isolate: _isolate, + timeout: _postgrestOptions.timeout, + urlLengthLimit: _postgrestOptions.urlLengthLimit, ); } @@ -308,6 +310,8 @@ class SupabaseClient { schema: _postgrestOptions.schema, httpClient: _authHttpClient, isolate: _isolate, + timeout: _postgrestOptions.timeout, + urlLengthLimit: _postgrestOptions.urlLengthLimit, ); } diff --git a/packages/supabase/lib/src/supabase_client_options.dart b/packages/supabase/lib/src/supabase_client_options.dart index 12045bed6..721b1e884 100644 --- a/packages/supabase/lib/src/supabase_client_options.dart +++ b/packages/supabase/lib/src/supabase_client_options.dart @@ -3,7 +3,19 @@ import 'package:supabase/supabase.dart'; class PostgrestClientOptions { final String schema; - const PostgrestClientOptions({this.schema = 'public'}); + /// Optional timeout in milliseconds for PostgREST requests. When set, + /// requests automatically abort after this duration to prevent indefinite hangs. + final int? timeout; + + /// Maximum URL length in characters before a warning is logged. Defaults to 8000. + /// Protects against exceeding server URL limits with large queries. + final int urlLengthLimit; + + const PostgrestClientOptions({ + this.schema = 'public', + this.timeout, + this.urlLengthLimit = 8000, + }); } class AuthClientOptions { diff --git a/packages/supabase/lib/src/supabase_query_builder.dart b/packages/supabase/lib/src/supabase_query_builder.dart index dd31713a3..14bd9a931 100644 --- a/packages/supabase/lib/src/supabase_query_builder.dart +++ b/packages/supabase/lib/src/supabase_query_builder.dart @@ -15,6 +15,8 @@ class SupabaseQueryBuilder extends PostgrestQueryBuilder { super.httpClient, required int incrementId, required super.isolate, + super.timeout, + super.urlLengthLimit = 8000, }) : _realtime = realtime, _schema = schema, _table = table, diff --git a/packages/supabase/lib/src/supabase_query_schema.dart b/packages/supabase/lib/src/supabase_query_schema.dart index d40c49407..7b35fe2dd 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -45,6 +45,8 @@ class SupabaseQuerySchema { httpClient: _authHttpClient, incrementId: _counter.increment(), isolate: _isolate, + timeout: _rest.timeout, + urlLengthLimit: _rest.urlLengthLimit, ); }