Skip to content

Commit b994dab

Browse files
authored
feat(functions): add request cancellation to invoke via abortSignal (#1593)
## Summary Adds request cancellation to `FunctionsClient.invoke`, closing the gap versus the canonical compliance matrix. `invoke` now accepts an optional `abortSignal` Future; completing it cancels the in-flight HTTP request and throws `RequestAbortedException`, mirroring how the database client exposes cancellation through `PostgrestBuilder.abortSignal`. This is done idiomatically with the `http` package's `AbortableRequest`/`AbortableMultipartRequest` (wiring `abortTrigger`), so it works for both regular and multipart invocations. `RequestAbortedException` is re-exported from the package so callers can catch it. A per-invocation timeout is now expressible as `invoke(..., abortSignal: Future.delayed(duration))`, so the previously `not_applicable` `functions.invocation.timeout` note is updated accordingly. ## Outcome **implemented** ## Reference Mirrors the existing Dart `database.using_modifiers.request_cancellation` idiom (`PostgrestBuilder.abortSignal`). ## Compliance matrix - `functions.invocation.request_cancellation` → **implemented** (symbol `FunctionsClient.invoke` registered) ## Tests - Aborting an in-flight request throws `RequestAbortedException` - Aborting a multipart request throws `RequestAbortedException` - A never-firing signal lets the request complete normally SDK-1310
1 parent 59d349e commit b994dab

5 files changed

Lines changed: 107 additions & 8 deletions

File tree

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/// A dart client library for Supabase Edge Functions.
22
library;
33

4-
export 'package:http/http.dart' show ByteStream, MultipartFile;
4+
export 'package:http/http.dart'
5+
show ByteStream, MultipartFile, RequestAbortedException;
56

67
export 'src/functions_client.dart';
78
export 'src/types.dart';

packages/functions_client/lib/src/functions_client.dart

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,32 @@ class FunctionsClient {
6666
/// [region] optionally specify the region to invoke the function in.
6767
/// When specified, adds both `x-region` header and `forceFunctionRegion` query parameter.
6868
///
69+
/// [abortSignal] cancels the in-flight request when the provided [Future]
70+
/// completes. It must not complete with an error. On abort, a
71+
/// [RequestAbortedException] is thrown. This is useful for cancelling a
72+
/// request in response to an event or for setting a request timeout:
73+
///
74+
/// ```dart
75+
/// // Event based
76+
/// final abortSignal = Completer<void>();
77+
/// abortSignal.complete(); // Call in some event handler to abort the request
78+
///
79+
/// try {
80+
/// final response = await supabase.functions.invoke(
81+
/// 'hello-world',
82+
/// abortSignal: abortSignal.future,
83+
/// );
84+
/// } on RequestAbortedException catch (error) {
85+
/// print('Request was aborted: $error');
86+
/// }
87+
///
88+
/// // Timer based
89+
/// final response = await supabase.functions.invoke(
90+
/// 'hello-world',
91+
/// abortSignal: Future.delayed(Duration(seconds: 5)),
92+
/// );
93+
/// ```
94+
///
6995
/// ```dart
7096
/// // Call a standard function
7197
/// final response = await supabase.functions.invoke('hello-world');
@@ -98,6 +124,7 @@ class FunctionsClient {
98124
Map<String, dynamic>? queryParameters,
99125
HttpMethod method = HttpMethod.post,
100126
String? region,
127+
Future<void>? abortSignal,
101128
}) async {
102129
final effectiveRegion = region ?? _region;
103130

@@ -137,11 +164,20 @@ class FunctionsClient {
137164
);
138165
final fields = (body as Map?)?.cast<String, String>();
139166

140-
request = http.MultipartRequest(method.name.toUpperCase(), uri)
141-
..fields.addAll(fields ?? {})
142-
..files.addAll(files);
167+
request =
168+
http.AbortableMultipartRequest(
169+
method.name.toUpperCase(),
170+
uri,
171+
abortTrigger: abortSignal,
172+
)
173+
..fields.addAll(fields ?? {})
174+
..files.addAll(files);
143175
} else {
144-
final bodyRequest = http.Request(method.name.toUpperCase(), uri);
176+
final bodyRequest = http.AbortableRequest(
177+
method.name.toUpperCase(),
178+
uri,
179+
abortTrigger: abortSignal,
180+
);
145181

146182
if (body == null) {
147183
// No body to set
@@ -165,6 +201,8 @@ class FunctionsClient {
165201
final http.StreamedResponse response;
166202
try {
167203
response = await (_httpClient?.send(request) ?? request.send());
204+
} on http.RequestAbortedException {
205+
rethrow;
168206
} catch (error) {
169207
throw FunctionsFetchException(details: error);
170208
}

packages/functions_client/test/custom_http_client.dart

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,22 @@ class CustomHttpClient extends BaseClient {
1414
receivedRequests.add(request);
1515
request.finalize();
1616

17-
if (request.url.path.endsWith('network-error')) {
17+
if (request.url.path.endsWith('slow')) {
18+
// Hangs until the request is aborted, mirroring how a real client
19+
// surfaces abortion. Without an abort trigger it responds immediately so
20+
// non-abort tests don't stall.
21+
final abortTrigger = request is Abortable ? request.abortTrigger : null;
22+
if (abortTrigger != null) {
23+
await abortTrigger;
24+
throw RequestAbortedException(request.url);
25+
}
26+
return StreamedResponse(
27+
Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))),
28+
200,
29+
request: request,
30+
headers: {"Content-Type": "application/json"},
31+
);
32+
} else if (request.url.path.endsWith('network-error')) {
1833
// Simulate a transport failure before any response is received.
1934
throw ClientException('Connection failed', request.url);
2035
} else if (request.url.path.endsWith('relay-error')) {

packages/functions_client/test/functions_dart_test.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:convert';
23
import 'dart:typed_data';
34

@@ -530,6 +531,47 @@ void main() {
530531
});
531532
});
532533

534+
group('Request cancellation', () {
535+
test('aborts an in-flight request via abortSignal', () async {
536+
final abortSignal = Completer<void>();
537+
Timer(const Duration(milliseconds: 50), abortSignal.complete);
538+
539+
await expectLater(
540+
functionsCustomHttpClient.invoke(
541+
'slow',
542+
abortSignal: abortSignal.future,
543+
),
544+
throwsA(isA<RequestAbortedException>()),
545+
);
546+
});
547+
548+
test('aborts a multipart request via abortSignal', () async {
549+
final abortSignal = Completer<void>();
550+
Timer(const Duration(milliseconds: 50), abortSignal.complete);
551+
552+
await expectLater(
553+
functionsCustomHttpClient.invoke(
554+
'slow',
555+
files: [MultipartFile.fromString('file', 'content')],
556+
abortSignal: abortSignal.future,
557+
),
558+
throwsA(isA<RequestAbortedException>()),
559+
);
560+
});
561+
562+
test('completes normally when the signal never fires', () async {
563+
final abortSignal = Completer<void>();
564+
565+
final response = await functionsCustomHttpClient.invoke(
566+
'function',
567+
abortSignal: abortSignal.future,
568+
);
569+
570+
expect(response.status, 200);
571+
expect(response.data, {'key': 'Hello World'});
572+
});
573+
});
574+
533575
group('Response content types', () {
534576
test('handles application/octet-stream response', () async {
535577
final response = await functionsCustomHttpClient.invoke('binary');

sdk-compliance.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,8 +695,11 @@ features:
695695
functions.invocation.region_selection: implemented
696696
functions.invocation.timeout:
697697
status: not_applicable
698-
note: "A per-invocation timeout would only wrap the returned Future and cannot abort the in-flight request, so a caller could still see the function succeed after a timeout fires. That is identical to the caller applying Future.timeout themselves, so exposing it as an option adds no capability and would be misleading."
699-
functions.invocation.request_cancellation: not_implemented
698+
note: "A genuine timeout that aborts the in-flight request is already expressible as `invoke(..., abortSignal: Future.delayed(duration))` via functions.invocation.request_cancellation, so a dedicated timeout option would add no capability over that."
699+
functions.invocation.request_cancellation:
700+
status: implemented
701+
symbols:
702+
- FunctionsClient.invoke
700703

701704
# client
702705
client.authentication_integration.third_party_auth: implemented

0 commit comments

Comments
 (0)