Skip to content

Commit fdf65a3

Browse files
grdsdevclaude
andcommitted
feat(postgrest): add URL length validation and timeout protection
Adds two new configuration options to PostgrestClient to prevent indefinite hangs on queries with very long URLs (10k+ characters): - `timeout`: Optional int (milliseconds) to auto-abort requests via Future.timeout, propagated through PostgrestClientOptions - `urlLengthLimit`: Max URL length (default 8000) before logging a warning, guiding users toward views or RPC functions for large queries Both options propagate from PostgrestClient through all builders (PostgrestQueryBuilder, PostgrestRpcBuilder) and from SupabaseClient via PostgrestClientOptions. Acceptance Criteria: - [x] timeout option added to PostgrestClient and SupabaseClient - [x] urlLengthLimit option added with default of 8000 - [x] URL length validation before request execution - [x] Timeout auto-cancels requests via Future.timeout - [x] Unit tests cover timeout and URL length scenarios Linear: SDK-698 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0719e55 commit fdf65a3

10 files changed

Lines changed: 243 additions & 8 deletions

packages/postgrest/lib/src/postgrest.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ class PostgrestClient {
1212
final Client? httpClient;
1313
final YAJsonIsolate _isolate;
1414
final bool _hasCustomIsolate;
15+
16+
/// Optional timeout in milliseconds for PostgREST requests. When set,
17+
/// requests automatically abort after this duration to prevent indefinite hangs.
18+
final int? timeout;
19+
20+
/// Maximum URL length in characters before a warning is logged. Defaults to 8000.
21+
/// Protects against exceeding server URL limits with large queries.
22+
final int urlLengthLimit;
1523
final _log = Logger('supabase.postgrest');
1624

1725
/// To create a [PostgrestClient], you need to provide an [url] endpoint.
@@ -25,12 +33,18 @@ class PostgrestClient {
2533
/// [httpClient] is optional and can be used to provide a custom http client
2634
///
2735
/// [isolate] is optional and can be used to provide a custom isolate, which is used for heavy json computation
36+
///
37+
/// [timeout] is optional and can be used to set a timeout in milliseconds for requests
38+
///
39+
/// [urlLengthLimit] is optional and can be used to set the maximum URL length before a warning is logged. Defaults to 8000.
2840
PostgrestClient(
2941
this.url, {
3042
Map<String, String>? headers,
3143
String? schema,
3244
this.httpClient,
3345
YAJsonIsolate? isolate,
46+
this.timeout,
47+
this.urlLengthLimit = 8000,
3448
}) : _schema = schema,
3549
headers = {...defaultHeaders, if (headers != null) ...headers},
3650
_isolate = isolate ?? (YAJsonIsolate()..initialize()),
@@ -65,6 +79,8 @@ class PostgrestClient {
6579
schema: _schema,
6680
httpClient: httpClient,
6781
isolate: _isolate,
82+
timeout: timeout,
83+
urlLengthLimit: urlLengthLimit,
6884
);
6985
}
7086

@@ -78,6 +94,8 @@ class PostgrestClient {
7894
schema: schema,
7995
httpClient: httpClient,
8096
isolate: _isolate,
97+
timeout: timeout,
98+
urlLengthLimit: urlLengthLimit,
8199
);
82100
}
83101

@@ -108,6 +126,8 @@ class PostgrestClient {
108126
schema: _schema,
109127
httpClient: httpClient,
110128
isolate: _isolate,
129+
timeout: timeout,
130+
urlLengthLimit: urlLengthLimit,
111131
).rpc(params, get);
112132
}
113133

packages/postgrest/lib/src/postgrest_builder.dart

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
4545
final Client? _httpClient;
4646
final YAJsonIsolate? _isolate;
4747
final CountOption? _count;
48+
49+
/// Optional timeout in milliseconds for this request. When set, the request
50+
/// automatically aborts after this duration to prevent indefinite hangs.
51+
final int? _timeout;
52+
53+
/// Maximum URL length in characters before a warning is logged. Defaults to 8000.
54+
final int _urlLengthLimit;
4855
final _log = Logger('supabase.postgrest');
4956

5057
PostgrestBuilder({
@@ -58,6 +65,8 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
5865
CountOption? count,
5966
bool maybeSingle = false,
6067
PostgrestConverter<S, R>? converter,
68+
int? timeout,
69+
int urlLengthLimit = 8000,
6170
}) : _maybeSingle = maybeSingle,
6271
_method = method,
6372
_converter = converter,
@@ -67,7 +76,9 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
6776
_httpClient = httpClient,
6877
_isolate = isolate,
6978
_count = count,
70-
_body = body;
79+
_body = body,
80+
_timeout = timeout,
81+
_urlLengthLimit = urlLengthLimit;
7182

7283
PostgrestBuilder<T, S, R> _copyWith({
7384
Uri? url,
@@ -92,6 +103,8 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
92103
count: count ?? _count,
93104
maybeSingle: maybeSingle ?? _maybeSingle,
94105
converter: converter ?? _converter,
106+
timeout: _timeout,
107+
urlLengthLimit: _urlLengthLimit,
95108
);
96109
}
97110

@@ -113,6 +126,15 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
113126
}
114127
}
115128

129+
final urlLength = _url.toString().length;
130+
if (urlLength > _urlLengthLimit) {
131+
_log.warning(
132+
'Request URL is $urlLength characters, which exceeds the limit of $_urlLengthLimit. '
133+
'If selecting many fields, consider using a view. '
134+
'If filtering with large arrays, consider using an RPC function.',
135+
);
136+
}
137+
116138
try {
117139
if (method == null) {
118140
throw ArgumentError(
@@ -136,39 +158,51 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
136158
final bodyStr = jsonEncode(_body);
137159
_log.finest("Request: $uppercaseMethod $_url");
138160

161+
Future<http.Response> responseFuture;
162+
139163
if (uppercaseMethod == METHOD_GET) {
140-
response = await (_httpClient?.get ?? http.get)(
164+
responseFuture = (_httpClient?.get ?? http.get)(
141165
_url,
142166
headers: _headers,
143167
);
144168
} else if (uppercaseMethod == METHOD_POST) {
145-
response = await (_httpClient?.post ?? http.post)(
169+
responseFuture = (_httpClient?.post ?? http.post)(
146170
_url,
147171
headers: _headers,
148172
body: bodyStr,
149173
);
150174
} else if (uppercaseMethod == METHOD_PUT) {
151-
response = await (_httpClient?.put ?? http.put)(
175+
responseFuture = (_httpClient?.put ?? http.put)(
152176
_url,
153177
headers: _headers,
154178
body: bodyStr,
155179
);
156180
} else if (uppercaseMethod == METHOD_PATCH) {
157-
response = await (_httpClient?.patch ?? http.patch)(
181+
responseFuture = (_httpClient?.patch ?? http.patch)(
158182
_url,
159183
headers: _headers,
160184
body: bodyStr,
161185
);
162186
} else if (uppercaseMethod == METHOD_DELETE) {
163-
response = await (_httpClient?.delete ?? http.delete)(
187+
responseFuture = (_httpClient?.delete ?? http.delete)(
164188
_url,
165189
headers: _headers,
166190
);
167191
} else if (uppercaseMethod == METHOD_HEAD) {
168-
response = await (_httpClient?.head ?? http.head)(
192+
responseFuture = (_httpClient?.head ?? http.head)(
169193
_url,
170194
headers: _headers,
171195
);
196+
} else {
197+
throw ArgumentError('Unsupported HTTP method: $method');
198+
}
199+
200+
if (_timeout != null) {
201+
response = await responseFuture.timeout(
202+
Duration(milliseconds: _timeout!),
203+
);
204+
} else {
205+
response = await responseFuture;
172206
}
173207

174208
return _parseResponse(response, method);

packages/postgrest/lib/src/postgrest_query_builder.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class PostgrestQueryBuilder<T> extends RawPostgrestBuilder<T, T, T> {
1919
String? schema,
2020
Client? httpClient,
2121
YAJsonIsolate? isolate,
22+
int? timeout,
23+
int urlLengthLimit = 8000,
2224
}) : super(
2325
PostgrestBuilder(
2426
url: url,
@@ -27,6 +29,8 @@ class PostgrestQueryBuilder<T> extends RawPostgrestBuilder<T, T, T> {
2729
schema: schema,
2830
httpClient: httpClient,
2931
isolate: isolate,
32+
timeout: timeout,
33+
urlLengthLimit: urlLengthLimit,
3034
),
3135
);
3236

packages/postgrest/lib/src/postgrest_rpc_builder.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder {
77
String? schema,
88
Client? httpClient,
99
required YAJsonIsolate isolate,
10+
int? timeout,
11+
int urlLengthLimit = 8000,
1012
}) : super(
1113
PostgrestBuilder(
1214
url: Uri.parse(url),
1315
headers: headers ?? {},
1416
schema: schema,
1517
httpClient: httpClient,
1618
isolate: isolate,
19+
timeout: timeout,
20+
urlLengthLimit: urlLengthLimit,
1721
),
1822
);
1923

packages/postgrest/lib/src/raw_postgrest_builder.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class RawPostgrestBuilder<T, S, R> extends PostgrestBuilder<T, S, R> {
1414
isolate: builder._isolate,
1515
maybeSingle: builder._maybeSingle,
1616
converter: builder._converter,
17+
timeout: builder._timeout,
18+
urlLengthLimit: builder._urlLengthLimit,
1719
);
1820

1921
/// Very similar to [_copyWith], but allows changing the generics, therefore [_converter] is omitted
@@ -38,6 +40,8 @@ class RawPostgrestBuilder<T, S, R> extends PostgrestBuilder<T, S, R> {
3840
isolate: isolate ?? _isolate,
3941
count: count ?? _count,
4042
maybeSingle: maybeSingle ?? _maybeSingle,
43+
timeout: _timeout,
44+
urlLengthLimit: _urlLengthLimit,
4145
));
4246
}
4347

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import 'dart:async';
2+
import 'dart:typed_data';
3+
4+
import 'package:http/http.dart';
5+
import 'package:logging/logging.dart';
6+
import 'package:postgrest/postgrest.dart';
7+
import 'package:test/test.dart';
8+
9+
class _DelayedHttpClient extends BaseClient {
10+
final Duration delay;
11+
final int statusCode;
12+
final String body;
13+
14+
_DelayedHttpClient({
15+
required this.delay,
16+
this.statusCode = 200,
17+
this.body = '[]',
18+
});
19+
20+
@override
21+
Future<StreamedResponse> send(BaseRequest request) async {
22+
await Future.delayed(delay);
23+
return StreamedResponse(
24+
Stream.value(Uint8List.fromList(body.codeUnits)),
25+
statusCode,
26+
request: request,
27+
);
28+
}
29+
}
30+
31+
class _InstantHttpClient extends BaseClient {
32+
final int statusCode;
33+
final String body;
34+
35+
_InstantHttpClient({this.statusCode = 200, this.body = '[]'});
36+
37+
@override
38+
Future<StreamedResponse> send(BaseRequest request) async {
39+
return StreamedResponse(
40+
Stream.value(Uint8List.fromList(body.codeUnits)),
41+
statusCode,
42+
request: request,
43+
);
44+
}
45+
}
46+
47+
void main() {
48+
group('URL length validation', () {
49+
test('logs warning when URL exceeds urlLengthLimit', () async {
50+
final warnings = <String>[];
51+
final subscription = Logger.root.onRecord.listen((record) {
52+
if (record.level == Level.WARNING) {
53+
warnings.add(record.message);
54+
}
55+
});
56+
Logger.root.level = Level.ALL;
57+
58+
final client = PostgrestClient(
59+
'http://localhost:3000',
60+
httpClient: _InstantHttpClient(),
61+
urlLengthLimit: 50,
62+
);
63+
64+
// URL: http://localhost:3000/users?select=id,username,email,created_at — well over 50 chars
65+
await client
66+
.from('users')
67+
.select('id,username,email,created_at')
68+
.catchError((_) => []);
69+
70+
await subscription.cancel();
71+
72+
expect(
73+
warnings.any((w) => w.contains('exceeds the limit of 50')),
74+
isTrue,
75+
reason: 'Expected a warning about URL length',
76+
);
77+
});
78+
79+
test('does not log warning when URL is within urlLengthLimit', () async {
80+
final warnings = <String>[];
81+
final subscription = Logger.root.onRecord.listen((record) {
82+
if (record.level == Level.WARNING &&
83+
record.message.contains('exceeds the limit')) {
84+
warnings.add(record.message);
85+
}
86+
});
87+
Logger.root.level = Level.ALL;
88+
89+
final client = PostgrestClient(
90+
'http://localhost:3000',
91+
httpClient: _InstantHttpClient(),
92+
urlLengthLimit: 8000,
93+
);
94+
95+
await client.from('users').select().catchError((_) => []);
96+
97+
await subscription.cancel();
98+
99+
expect(warnings, isEmpty, reason: 'Expected no URL length warning');
100+
});
101+
102+
test('default urlLengthLimit is 8000', () {
103+
final client = PostgrestClient('http://localhost:3000');
104+
expect(client.urlLengthLimit, equals(8000));
105+
});
106+
107+
test('custom urlLengthLimit is stored', () {
108+
final client =
109+
PostgrestClient('http://localhost:3000', urlLengthLimit: 5000);
110+
expect(client.urlLengthLimit, equals(5000));
111+
});
112+
});
113+
114+
group('Timeout configuration', () {
115+
test('timeout is null by default', () {
116+
final client = PostgrestClient('http://localhost:3000');
117+
expect(client.timeout, isNull);
118+
});
119+
120+
test('custom timeout is stored', () {
121+
final client = PostgrestClient('http://localhost:3000', timeout: 5000);
122+
expect(client.timeout, equals(5000));
123+
});
124+
125+
test('request times out when response is delayed beyond timeout', () async {
126+
final client = PostgrestClient(
127+
'http://localhost:3000',
128+
httpClient: _DelayedHttpClient(delay: const Duration(seconds: 5)),
129+
timeout: 100, // 100ms timeout
130+
);
131+
132+
expect(
133+
() => client.from('users').select(),
134+
throwsA(isA<TimeoutException>()),
135+
);
136+
});
137+
138+
test('request succeeds when response is within timeout', () async {
139+
final client = PostgrestClient(
140+
'http://localhost:3000',
141+
httpClient: _InstantHttpClient(body: '[{"id": 1}]'),
142+
timeout: 5000, // 5 second timeout
143+
);
144+
145+
final result = await client.from('users').select();
146+
expect(result, isA<List>());
147+
});
148+
});
149+
}

packages/supabase/lib/src/supabase_client.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ class SupabaseClient {
183183
httpClient: _authHttpClient,
184184
incrementId: _incrementId.increment(),
185185
isolate: _isolate,
186+
timeout: _postgrestOptions.timeout,
187+
urlLengthLimit: _postgrestOptions.urlLengthLimit,
186188
);
187189
}
188190

@@ -303,6 +305,8 @@ class SupabaseClient {
303305
schema: _postgrestOptions.schema,
304306
httpClient: _authHttpClient,
305307
isolate: _isolate,
308+
timeout: _postgrestOptions.timeout,
309+
urlLengthLimit: _postgrestOptions.urlLengthLimit,
306310
);
307311
}
308312

0 commit comments

Comments
 (0)