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
55 changes: 53 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,5 +655,56 @@ Four changes go beyond a rename:

`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.
`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.

### The Iceberg exceptions join the same hierarchy

`IcebergException` used `0` as the status code when a request never reached the catalog, so callers
had to know that `statusCode == 0` meant "no response" rather than a real status. The sealed
hierarchy now splits the same way as the other packages:

| | |
| --- | --- |
| `IcebergNetworkException` | the request never reached the catalog, so there is no status code |
| `IcebergApiException` | the catalog answered, so `statusCode` is a real, non-nullable status |

`IcebergApiException` is the sealed base for the response backed subtypes, which are unchanged:
`IcebergNotFoundException`, `IcebergConflictException`,
`IcebergAuthenticationTimeoutException`, `IcebergCommitStateUnknownException`,
`IcebergServerException` and `IcebergUnknownException`.

| Before | After |
| --- | --- |
| `IcebergException.type` | `errorCode`, from `SupabaseException` |
| `IcebergException.statusCode` | `IcebergApiException.statusCode`; gone from the network case |
| `IcebergException.statusCode == 0` | catch `IcebergNetworkException`, or check `is SupabaseApiException` |
| `IcebergException.fromResponse` | `IcebergApiException.fromResponse` |

`message`, `code` and `details` keep their names. `code` is still the Iceberg numeric error code,
which is unrelated to `errorCode`, the string error type such as `NoSuchTableException`.

```dart
// Before
try {
await catalog.loadTable(id);
} on IcebergException catch (error) {
if (error.statusCode == 0) {
// the request never went out
}
print(error.type);
}

// After
try {
await catalog.loadTable(id);
} on IcebergNetworkException catch (error) {
// the request never went out
print(error.details);
} on IcebergApiException catch (error) {
print('${error.statusCode}: ${error.errorCode}');
}
```

Exhaustive switches over the sealed hierarchy still compile with the same set of cases, since the
new base is sealed and every concrete subtype is unchanged.
100 changes: 56 additions & 44 deletions packages/storage_client/lib/src/iceberg/iceberg_error.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import 'package:supabase_common/supabase_common.dart';

/// Error thrown by [IcebergRestCatalog] operations when the Iceberg REST
/// Catalog API returns an error response or a request fails at the network
/// level.
///
/// A request that never reached the catalog is an [IcebergNetworkException].
/// Anything the catalog answered with is an [IcebergApiException] and carries
/// the response's status code.
///
/// This is a sealed hierarchy: match on the concrete subtype to handle a
/// specific failure, for example
///
Expand All @@ -14,45 +20,57 @@
/// // any other Iceberg failure
/// }
/// ```
sealed class IcebergException implements Exception {
/// Human readable error message.
final String message;

/// The HTTP status code of the response. `0` indicates a network level
/// failure before a response was received.
final int statusCode;

/// The Iceberg error type reported by the server, for example
/// `NoSuchTableException`.
final String? type;

sealed class IcebergException extends SupabaseException {
/// The Iceberg error code reported by the server.
final int? code;

/// The raw error payload, when available.
final Object? details;

const IcebergException(
this.message, {
required this.statusCode,
this.type,
super.message, {
super.errorCode,
this.code,
this.details,
});
}

/// Builds the appropriate [IcebergException] subtype from an error response.
factory IcebergException.fromResponse(int statusCode, Object? body) {
/// A request failed at the network level, before any response was received.
final class IcebergNetworkException extends IcebergException {
const IcebergNetworkException(super.message, {super.details});
}

/// The Iceberg REST Catalog API answered with an error response.
///
/// [errorCode] holds the Iceberg error type, for example
/// `NoSuchTableException`.
sealed class IcebergApiException extends IcebergException
with SupabaseApiException {
@override
final int statusCode;

const IcebergApiException(
super.message, {
required this.statusCode,
super.errorCode,
super.code,
super.details,
});

/// Builds the appropriate [IcebergApiException] subtype from an error
/// response.
factory IcebergApiException.fromResponse(int statusCode, Object? body) {
var message = 'Request failed with status $statusCode';
String? type;
String? errorCode;
int? code;
if (body is Map<String, dynamic> && body['error'] is Map) {
final error = body['error'] as Map<String, dynamic>;
message = (error['message'] as String?) ?? message;
type = error['type'] as String?;
errorCode = error['type'] as String?;
code = error['code'] as int?;
}

if (type == 'CommitStateUnknownException') {
if (errorCode == 'CommitStateUnknownException') {
return IcebergCommitStateUnknownException(
message,
statusCode: statusCode,
Expand All @@ -64,33 +82,33 @@ sealed class IcebergException implements Exception {
return switch (statusCode) {
404 => IcebergNotFoundException(
message,
type: type,
errorCode: errorCode,
code: code,
details: body,
),
409 => IcebergConflictException(
message,
type: type,
errorCode: errorCode,
code: code,
details: body,
),
419 => IcebergAuthenticationTimeoutException(
message,
type: type,
errorCode: errorCode,
code: code,
details: body,
),
>= 500 => IcebergServerException(
message,
statusCode: statusCode,
type: type,
errorCode: errorCode,
code: code,
details: body,
),
_ => IcebergUnknownException(
message,
statusCode: statusCode,
type: type,
errorCode: errorCode,
code: code,
details: body,
),
Expand All @@ -100,75 +118,69 @@ sealed class IcebergException implements Exception {
@override
String toString() =>
'$runtimeType(message: $message, statusCode: $statusCode, '
'type: $type, code: $code)';
}

/// A request failed at the network level, before any response was received.
final class IcebergNetworkException extends IcebergException {
const IcebergNetworkException(super.message, {super.details})
: super(statusCode: 0);
'errorCode: $errorCode, code: $code)';
}

/// The requested namespace or table does not exist (HTTP 404).
final class IcebergNotFoundException extends IcebergException {
final class IcebergNotFoundException extends IcebergApiException {
const IcebergNotFoundException(
super.message, {
super.type,
super.errorCode,
super.code,
super.details,
}) : super(statusCode: 404);
}

/// The request conflicts with the current state, for example the resource
/// already exists or a commit lost a race (HTTP 409).
final class IcebergConflictException extends IcebergException {
final class IcebergConflictException extends IcebergApiException {
const IcebergConflictException(
super.message, {
super.type,
super.errorCode,
super.code,
super.details,
}) : super(statusCode: 409);
}

/// Authentication timed out and the request should be retried with fresh
/// credentials (HTTP 419).
final class IcebergAuthenticationTimeoutException extends IcebergException {
final class IcebergAuthenticationTimeoutException extends IcebergApiException {
const IcebergAuthenticationTimeoutException(
super.message, {
super.type,
super.errorCode,
super.code,
super.details,
}) : super(statusCode: 419);
}

/// A table commit was sent but its outcome is unknown, so retrying it could
/// duplicate data.
final class IcebergCommitStateUnknownException extends IcebergException {
final class IcebergCommitStateUnknownException extends IcebergApiException {
const IcebergCommitStateUnknownException(
super.message, {
required super.statusCode,
super.code,
super.details,
}) : super(type: 'CommitStateUnknownException');
}) : super(errorCode: 'CommitStateUnknownException');
}

/// The server failed to handle the request (HTTP 5xx).
final class IcebergServerException extends IcebergException {
final class IcebergServerException extends IcebergApiException {
const IcebergServerException(
super.message, {
required super.statusCode,
super.type,
super.errorCode,
super.code,
super.details,
});
}

/// Any Iceberg failure that does not fit a more specific subtype.
final class IcebergUnknownException extends IcebergException {
final class IcebergUnknownException extends IcebergApiException {
const IcebergUnknownException(
super.message, {
required super.statusCode,
super.type,
super.errorCode,
super.code,
super.details,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ class IcebergRestCatalog {
: response.body;

if (response.statusCode < 200 || response.statusCode >= 300) {
throw IcebergException.fromResponse(response.statusCode, decoded);
throw IcebergApiException.fromResponse(response.statusCode, decoded);
}

return _IcebergResponse(response.statusCode, response.headers, decoded);
Expand Down
47 changes: 43 additions & 4 deletions packages/storage_client/test/iceberg_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart';
import 'package:storage_client/storage_client.dart';
Expand Down Expand Up @@ -526,7 +527,7 @@ void main() {
});

group('errors and serialization', () {
test('IcebergException carries type and code', () async {
test('IcebergApiException carries the error code and code', () async {
mockClient.handler = (request) => _json(
{
'error': {
Expand All @@ -542,9 +543,13 @@ void main() {
await expectLater(
catalog.listNamespaces(),
throwsA(
isA<IcebergException>()
isA<IcebergApiException>()
.having((error) => error.statusCode, 'statusCode', 400)
.having((error) => error.type, 'type', 'BadRequestException')
.having(
(error) => error.errorCode,
'errorCode',
'BadRequestException',
)
.having((error) => error.code, 'code', 400),
),
);
Expand Down Expand Up @@ -589,13 +594,47 @@ void main() {
});

test('commit state unknown is its own subtype regardless of status', () {
final exception = IcebergException.fromResponse(500, {
final exception = IcebergApiException.fromResponse(500, {
'error': {'message': 'unknown', 'type': 'CommitStateUnknownException'},
});

expect(exception, isA<IcebergCommitStateUnknownException>());
});

test('only the response backed exceptions are SupabaseApiException', () {
final List<SupabaseException> serviceAnswered = [
IcebergApiException.fromResponse(404, null),
IcebergApiException.fromResponse(409, null),
IcebergApiException.fromResponse(419, null),
IcebergApiException.fromResponse(503, null),
IcebergApiException.fromResponse(400, null),
];

for (final exception in serviceAnswered) {
expect(exception, isA<SupabaseApiException>());
}

const SupabaseException network = IcebergNetworkException('no route');

expect(network, isNot(isA<SupabaseApiException>()));
});

test('a network failure keeps the originating error in details', () async {
mockClient.handler = (request) =>
throw const SocketException('no route to host');

await expectLater(
catalog.listNamespaces(),
throwsA(
isA<IcebergNetworkException>().having(
(error) => error.details,
'details',
isA<SocketException>(),
),
),
);
});

test('TableUpdate raw escape hatch serializes the action', () {
const update = TableUpdate.raw('add-snapshot', {
'snapshot': {'snapshot-id': 1},
Expand Down
10 changes: 5 additions & 5 deletions sdk-compliance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2208,6 +2208,11 @@ supporting_symbols:
- GoTruePasskeyApi.list
- GoTruePasskeyApi.update
- Headers
- IcebergApiException
- IcebergApiException.IcebergApiException
- IcebergApiException.fromResponse
- IcebergApiException.statusCode
- IcebergApiException.toString
- IcebergAuthenticationTimeoutException
- IcebergAuthenticationTimeoutException.IcebergAuthenticationTimeoutException
- IcebergCommitStateUnknownException
Expand All @@ -2218,11 +2223,6 @@ supporting_symbols:
- IcebergException.IcebergException
- IcebergException.code
- IcebergException.details
- IcebergException.fromResponse
- IcebergException.message
- IcebergException.statusCode
- IcebergException.toString
- IcebergException.type
- IcebergNetworkException
- IcebergNetworkException.IcebergNetworkException
- IcebergNotFoundException
Expand Down