diff --git a/MIGRATION.md b/MIGRATION.md index 23365ba1c..65a4366dc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -831,6 +831,32 @@ Four changes go beyond a rename: `RealtimeSubscribeException` is not part of this hierarchy: it reports a channel subscription outcome rather than a request failure, and carries a `RealtimeSubscribeStatus` instead of a message. +### `FunctionException` is sealed + +`FunctionException` is now a `sealed class`, so a switch over it is exhaustive at compile time and +adding a failure mode in a later version is a compile error rather than a case that silently falls +through: + +```dart +try { + await supabase.functions.invoke('hello'); +} on FunctionException catch (error) { + final message = switch (error) { + FunctionsFetchException() => 'The request never reached the function', + FunctionsRelayException() => 'The relay reported an error', + FunctionsApiException() => 'The function returned ${error.statusCode}', + }; +} +``` + +`FunctionsRelayException` extends `FunctionsApiException`, so it has to come first for its case to +be reachable. Only `FunctionsFetchException` and `FunctionsApiException` are needed for the switch +to be exhaustive. + +`sealed` implies `abstract`, so a bare `FunctionException` can no longer be constructed, and code +outside `supabase_functions` can no longer extend or implement it. Name one of the three subtypes +instead, which is what the client throws in every case. + ### The Iceberg exceptions join the same hierarchy `IcebergException` used `0` as the status code when no response was received, so callers diff --git a/packages/supabase_functions/lib/src/types.dart b/packages/supabase_functions/lib/src/types.dart index f300c92e0..f246ce222 100644 --- a/packages/supabase_functions/lib/src/types.dart +++ b/packages/supabase_functions/lib/src/types.dart @@ -27,10 +27,14 @@ class FunctionResponse { /// The response body, or the originating error when no response was received, /// is available in [details]. /// -/// A plain [FunctionException] is a failure the client raised on its own, such -/// as a request that never reached the function. A failure the function -/// answered with is a [FunctionsApiException]. -class FunctionException extends SupabaseException { +/// Use pattern matching over the specific subtypes: +/// - [FunctionsFetchException]: The request could not be sent (e.g. network +/// failure). +/// - [FunctionsApiException]: The Edge Function answered with a non-2xx status +/// code. +/// - [FunctionsRelayException]: The Supabase relay returned an error +/// (`x-relay-error`). +sealed class FunctionException extends SupabaseException { final dynamic details; const FunctionException({ diff --git a/packages/supabase_functions/test/functions_dart_test.dart b/packages/supabase_functions/test/functions_dart_test.dart index 7908f41ca..b0161a5c7 100644 --- a/packages/supabase_functions/test/functions_dart_test.dart +++ b/packages/supabase_functions/test/functions_dart_test.dart @@ -90,6 +90,24 @@ void main() { ); }); + test('exhaustive switch over FunctionException subtypes', () { + String describeError(FunctionException exception) { + return switch (exception) { + FunctionsFetchException(:final message) => 'fetch: $message', + FunctionsRelayException(:final statusCode) => 'relay: $statusCode', + FunctionsApiException(:final statusCode) => 'api: $statusCode', + }; + } + + const fetchError = FunctionsFetchException(message: 'Connection failed'); + const relayError = FunctionsRelayException(statusCode: 500); + const apiError = FunctionsApiException(statusCode: 400); + + expect(describeError(fetchError), 'fetch: Connection failed'); + expect(describeError(relayError), 'relay: 500'); + expect(describeError(apiError), 'api: 400'); + }); + test( 'error response with a streaming content type exposes the body', () async {