Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,3 +561,99 @@ constant held.
The `MigrationLocalStorage` and `HiveLocalStorage` snippets that migrated a v1 session out of
[hive](https://pub.dev/packages/hive) are gone from the README along with it. If you are still on
v1, upgrade to v2 first and let it migrate the session, then move to v3.

### Service exceptions share one base

`AuthException`, `PostgrestException`, `StorageException` and `FunctionException` each reimplemented
the same message plus status shape under different field names and types. They now extend a shared
`SupabaseException`, and the ones reporting a response from a service also mix in
`SupabaseApiException`:

```dart
abstract class SupabaseException implements Exception {
final String message;
final String? errorCode;
}

mixin SupabaseApiException on SupabaseException {
int get statusCode;
}
```

So `statusCode` is a non-nullable `int` that exists exactly when a service answered, and a failure
the client raised on its own carries only a message and, where the client can name it, an
`errorCode`. Both types are re-exported from every package, so one catch handles a failure from any
service:

```dart
try {
await supabase.from('countries').select();
} on SupabaseApiException catch (error) {
print('${error.statusCode}: ${error.message}');
} on SupabaseException catch (error) {
print(error.message);
}
```

The renames:

| Before | After |
| --- | --- |
| `AuthException.statusCode` (`String?`) | `AuthApiException.statusCode` (`int`) |
| `AuthException.code` | `AuthException.errorCode` |
| `StorageException.statusCode` (`String?`) | `StorageApiException.statusCode` (`int`) |
| `StorageException.error` | `errorCode` |
| `StorageException.fromJson(json, '404')` | `StorageApiException.fromJson(json, 404)` |
| `PostgrestException` | `PostgrestApiException` |
| `PostgrestException.code` | `PostgrestApiException.errorCode`, with the HTTP status in `statusCode` |
| `PostgrestException.fromJson(json, code: 409)` | `PostgrestApiException.fromJson(json, statusCode: 409)` |
| `PostgrestException.toJson()` key `code` | keys `statusCode` and `errorCode` |
| `FunctionsHttpException` | `FunctionsApiException` |
| `FunctionException.status` (`int`) | `FunctionsApiException.statusCode` |
| `FunctionException.reasonPhrase` | folded into `message` |
| `FunctionsFetchException.status == 0` | no status at all, no response reached the client |
| `FunctionResponse.status` | `FunctionResponse.statusCode` |

Reading a status off a per-service base no longer compiles, since the base no longer has one.
Narrow the catch to the API type:

```dart
// Before
try {
await supabase.auth.signInWithPassword(email: email, password: password);
} on AuthException catch (error) {
if (error.statusCode == '429') {
// ...
}
}

// After
try {
await supabase.auth.signInWithPassword(email: email, password: password);
} on AuthApiException catch (error) {
if (error.statusCode == 429) {
// ...
}
}
```

Four changes go beyond a rename:

- `PostgrestException.code` no longer doubles as the status. It held the PostgREST or PostgreSQL
code, except when the error body was not JSON, where it held the HTTP status instead. `errorCode`
is now only ever the former and `statusCode` only ever the latter, so a duplicate key violation
reads as `statusCode: 409, errorCode: '23505'`.
- `AuthSessionMissingException` and `AuthInvalidJwtException` report no status. The `400` they used
to carry was invented by the client, which raises both without making a request. They report
`errorCode` values of `session_missing` and `invalid_jwt` instead.
- `AuthRetryableFetchException` covers only the transport case now, where the request never reached
the service. A retryable 5xx the service answered is an `AuthRetryableApiException`, which carries
the status. Catching `AuthRetryableFetchException` still gets both.
- `FunctionException` gained a message. It had only `status`, `details` and `reasonPhrase`. The
response's reason phrase becomes the message, falling back to a per-subtype default when the
response carries none, as over HTTP/2. The response body is still in `details`.

`AuthUnknownException` also no longer reports a status of its own. It derived one from
`originalError`, which it still exposes, so read it from there when that is an `http.Response`.
`RealtimeSubscribeException` and `IcebergException` are not part of this hierarchy and are
unchanged.
2 changes: 1 addition & 1 deletion examples/database_crud/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ class _TaskDialogState extends State<_TaskDialog> {
}

void _showError(Object error) {
final message = error is PostgrestException
final message = error is PostgrestApiException
? error.message
: error.toString();
messengerKey.currentState?.showSnackBar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ void main() {
await expectLater(
repository.countWords(''),
throwsA(
isA<FunctionException>()
.having((error) => error.status, 'status', 400)
isA<FunctionsApiException>()
.having((error) => error.statusCode, 'statusCode', 400)
.having(
(error) => (error.details as Map)['error'],
'details.error',
Expand Down
23 changes: 11 additions & 12 deletions examples/edge_functions/integration_test/invoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ void main() {
group('request', () {
testWidgets('POST sends a JSON body that comes back decoded', (_) async {
final response = await functions.invoke('echo', body: {'hello': 'world'});
expect(response.status, 200);
expect(response.statusCode, 200);
expect(response.data['method'], 'POST');
expect(response.data['body'], {'hello': 'world'});
});
Expand Down Expand Up @@ -168,7 +168,7 @@ void main() {
'echo',
queryParameters: {'status': '201'},
);
expect(response.status, 201);
expect(response.statusCode, 201);
});
});

Expand All @@ -179,8 +179,8 @@ void main() {
await expectLater(
functions.invoke('echo', queryParameters: {'status': '422'}),
throwsA(
isA<FunctionsHttpException>()
.having((error) => error.status, 'status', 422)
isA<FunctionsApiException>()
.having((error) => error.statusCode, 'statusCode', 422)
.having(
(error) => (error.details as Map)['error'],
'details.error',
Expand All @@ -199,8 +199,8 @@ void main() {
queryParameters: {'status': '500', 'format': 'text'},
),
throwsA(
isA<FunctionsHttpException>()
.having((error) => error.status, 'status', 500)
isA<FunctionsApiException>()
.having((error) => error.statusCode, 'statusCode', 500)
.having((error) => error.details, 'details', 'boom'),
),
);
Expand All @@ -210,8 +210,8 @@ void main() {
_,
) async {
// A client pointed at a closed port can never connect, so the request
// fails before any response, surfacing as a fetch exception with status
// 0.
// fails before any response, surfacing as a fetch exception without a
// status code.
final unreachable = FunctionsClient(
'http://127.0.0.1:1/functions/v1',
const {'apikey': supabasePublishableKey},
Expand All @@ -220,10 +220,9 @@ void main() {
await expectLater(
unreachable.invoke('echo'),
throwsA(
isA<FunctionsFetchException>().having(
(error) => error.status,
'status',
0,
allOf(
isA<FunctionsFetchException>(),
isNot(isA<SupabaseApiException>()),
),
),
);
Expand Down
5 changes: 4 additions & 1 deletion examples/edge_functions/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,12 @@ void _showError(Object error) {
String message;
if (error is FunctionException) {
final details = error.details;
final fallback = error is FunctionsApiException
? 'Function failed with status ${error.statusCode}'
: error.message;
message = details is Map && details['error'] is String
? details['error'] as String
: 'Function failed with status ${error.status}';
: fallback;
} else {
message = error.toString();
}
Expand Down
2 changes: 1 addition & 1 deletion examples/realtime_room/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ class _Composer extends StatelessWidget {
}

void _showError(Object error) {
final message = error is PostgrestException
final message = error is PostgrestApiException
? error.message
: error.toString();
messengerKey.currentState?.showSnackBar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ Future<void> main() async {
'get_countries',
body: {'name': 'The Shire'},
);
print('status: ${response.status}');
print('status: ${response.statusCode}');
print('data: ${response.data}');
} on FunctionsApiException catch (error) {
print('Function error: ${error.statusCode} ${error.details}');
} on FunctionException catch (error) {
print('Function error: ${error.status} ${error.details}');
print('Function error: ${error.message} ${error.details}');
}
}
3 changes: 2 additions & 1 deletion packages/functions_client/lib/functions_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ library;

export 'package:http/http.dart'
show ByteStream, MultipartFile, RequestAbortedException;
export 'package:supabase_common/supabase_common.dart' show HttpMethod;
export 'package:supabase_common/supabase_common.dart'
show HttpMethod, SupabaseApiException, SupabaseException;

export 'src/functions_client.dart';
export 'src/types.dart';
14 changes: 8 additions & 6 deletions packages/functions_client/lib/src/functions_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,19 +256,21 @@ class FunctionsClient {
}

if (isSuccessStatus) {
return FunctionResponse(data: data, status: response.statusCode);
return FunctionResponse(data: data, statusCode: response.statusCode);
}
// The reason phrase is the only message the response itself carries; when
// it is absent, as it is over HTTP/2, each exception uses its own default.
if (isRelayError) {
throw FunctionsRelayException(
status: response.statusCode,
statusCode: response.statusCode,
details: data,
reasonPhrase: response.reasonPhrase,
message: response.reasonPhrase,
);
}
throw FunctionsHttpException(
status: response.statusCode,
throw FunctionsApiException(
statusCode: response.statusCode,
details: data,
reasonPhrase: response.reasonPhrase,
message: response.reasonPhrase,
);
}

Expand Down
80 changes: 51 additions & 29 deletions packages/functions_client/lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';

import 'package:http/http.dart';
import 'package:supabase_common/supabase_common.dart';

class FunctionResponse {
/// The data returned by the function. Type depends on the header
Expand All @@ -11,58 +12,79 @@ class FunctionResponse {
/// - 'application/json': dynamic ([jsonDecode] is used)
/// - 'text/event-stream': [ByteStream]
final dynamic data;
final int status;

/// HTTP status code of the response.
final int statusCode;

const FunctionResponse({
this.data,
required this.status,
required this.statusCode,
});
}

class FunctionException implements Exception {
final int status;
/// Thrown when invoking an Edge Function fails.
///
/// 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 {
final dynamic details;
final String? reasonPhrase;

const FunctionException({
required this.status,
required String message,
this.details,
this.reasonPhrase,
});
}) : super(message);

@override
String toString() =>
'$runtimeType(status: $status, details: $details, reasonPhrase: '
'$reasonPhrase)';
String toString() => '$runtimeType(message: $message, details: $details)';
}

/// Thrown when the request to the Edge Function could not be sent, for example
/// because of a network or transport failure, before any response was received.
/// because of a network or transport failure.
///
/// The originating error is available in [details] and [status] is `0` since no
/// response reached the client.
/// The originating error is available in [details].
class FunctionsFetchException extends FunctionException {
const FunctionsFetchException({
super.details,
super.reasonPhrase,
}) : super(status: 0);
String? message,
}) : super(
message: message ?? 'Failed to send a request to the Edge Function',
);
}

/// Thrown when the Supabase relay returns an error while invoking the Edge
/// Function, indicated by the `x-relay-error` response header.
class FunctionsRelayException extends FunctionException {
const FunctionsRelayException({
required super.status,
/// Thrown when the Edge Function responded with a non-2xx status code.
///
/// The response body is available in [details].
class FunctionsApiException extends FunctionException
with SupabaseApiException {
@override
final int statusCode;

const FunctionsApiException({
required this.statusCode,
super.details,
super.reasonPhrase,
});
String? message,
}) : super(
message: message ?? 'Edge Function returned a non-2xx status code',
);

@override
String toString() =>
'$runtimeType(message: $message, statusCode: $statusCode, '
'details: $details)';
}

/// Thrown when the Edge Function itself responds with a non-2xx status code.
class FunctionsHttpException extends FunctionException {
const FunctionsHttpException({
required super.status,
/// Thrown when the Supabase relay returns an error while invoking the Edge
/// Function, indicated by the `x-relay-error` response header.
///
/// The function itself may never have run.
class FunctionsRelayException extends FunctionsApiException {
const FunctionsRelayException({
required super.statusCode,
super.details,
super.reasonPhrase,
});
String? message,
}) : super(message: message ?? 'Relay error invoking the Edge Function');
}
Loading
Loading