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
17 changes: 5 additions & 12 deletions packages/functions_client/lib/src/functions_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,12 @@ class FunctionsClient {
'x-region': effectiveRegion,
};

if (body != null &&
!finalHeaders.keys.any((k) => k.toLowerCase() == 'content-type')) {
finalHeaders['Content-Type'] = switch (body) {
if (body != null) {
setDefaultContentType(finalHeaders, switch (body) {
Uint8List() => 'application/octet-stream',
String() => 'text/plain',
_ => 'application/json',
};
});
}
final http.BaseRequest request;
if (files != null) {
Expand Down Expand Up @@ -200,19 +199,13 @@ class FunctionsClient {

final http.StreamedResponse response;
try {
response = await (_httpClient?.send(request) ?? request.send());
response = await sendRequest(request, httpClient: _httpClient);
} on http.RequestAbortedException {
rethrow;
} catch (error) {
throw FunctionsFetchException(details: error);
}
final responseType =
(response.headers['Content-Type'] ??
response.headers['content-type'] ??
'text/plain')
.split(';')[0]
.trim()
.toLowerCase();
final responseType = responseMediaType(response.headers) ?? 'text/plain';

final isRelayError = response.headers['x-relay-error'] == 'true';
final isSuccessStatus =
Expand Down
18 changes: 10 additions & 8 deletions packages/postgrest/lib/src/postgrest_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -541,8 +541,16 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
}
PostgrestException error;
if (response.request!.method != HttpMethod.head.value) {
try {
final errorJson = jsonDecode(response.body) as Map<String, dynamic>;
// A proxy or gateway in front of PostgREST can answer with anything, so
// an error body that is not a JSON object is surfaced as-is.
final errorJson = tryDecodeJsonObject(response.body);
if (errorJson == null) {
error = PostgrestException(
message: response.body,
statusCode: response.statusCode,
details: response.reasonPhrase,
);
} else {
error = PostgrestException.fromJson(
errorJson,
message: response.body,
Expand All @@ -553,12 +561,6 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
if (_maybeSingle) {
return _handleMaybeSingleError(response, error);
}
} catch (_) {
error = PostgrestException(
message: response.body,
statusCode: response.statusCode,
details: response.reasonPhrase,
);
}
} else {
error = PostgrestException(
Expand Down
15 changes: 12 additions & 3 deletions packages/postgrest/lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,27 @@ class PostgrestException extends SupabaseException {
this.hint,
}) : super(message);

/// Builds an exception from an error response body.
///
/// A JSON object is no guarantee that its fields carry the types PostgREST
/// documents, since a proxy or gateway in front of it can answer with a shape
/// of its own, so every field is read defensively. [message] is used when the
/// body reports none.
factory PostgrestException.fromJson(
Map<String, dynamic> json, {
String? message,
int? statusCode,
String? details,
}) {
final reportedMessage = json['message'];
return PostgrestException(
message: (json['message'] ?? message) as String,
message: reportedMessage is String
? reportedMessage
: (message ?? json.toString()),
statusCode: statusCode,
errorCode: json['code'] as String?,
errorCode: json['code']?.toString(),
details: (json['details'] ?? details),
hint: json['hint'] as String?,
hint: json['hint']?.toString(),
);
}

Expand Down
13 changes: 13 additions & 0 deletions packages/postgrest/test/basic_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,19 @@ void main() {
);
});

test('a JSON error body with unexpected field types still throws '
'a PostgrestException', () async {
await expectLater(
() => postgrestCustomHttpClient.from('gateway-json-error').select(),
throwsA(
isA<PostgrestException>()
.having((e) => e.statusCode, 'statusCode', 502)
.having((e) => e.message, 'message', 'Bad gateway')
.having((e) => e.errorCode, 'errorCode', '502'),
),
);
});

test('non-JSON body on 2xx response with maybeSingle throws', () async {
await expectLater(
() => postgrestCustomHttpClient
Expand Down
13 changes: 13 additions & 0 deletions packages/postgrest/test/custom_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ class CustomHttpClient extends BaseClient {
reasonPhrase: 'OK',
);
}
// A gateway error body: a JSON object, but with `code` as a number rather
// than the string PostgREST reports.
if (request.url.path.endsWith("gateway-json-error")) {
return StreamedResponse(
Stream.value(
utf8.encode(jsonEncode({'code': 502, 'message': 'Bad gateway'})),
),
502,
request: request,
reasonPhrase: 'Bad Gateway',
);
}

//Return custom status code to check for usage of this client.
return StreamedResponse(
Stream.value(lastBody!),
Expand Down
50 changes: 50 additions & 0 deletions packages/postgrest/test/maybe_single_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ class ZeroRowsHttpClient extends BaseClient {
}
}

/// Mimics PostgREST rejecting a `maybeSingle()` request because more than one
/// row matched, which is a real failure rather than an empty result.
class MultipleRowsHttpClient extends BaseClient {
@override
Future<StreamedResponse> send(BaseRequest request) async {
return StreamedResponse(
Stream.value(
utf8.encode(
jsonEncode({
'code': 'PGRST116',
'details':
'Results contain 2 rows, application/vnd.pgrst.object+json '
'requires 1 row',
'hint': 'Ask for more rows',
'message': 'JSON object requested, multiple (or no) rows returned',
}),
),
),
406,
request: request,
);
}
}

void main() {
test(
'maybeSingle().count() returns null data and count 0 when no rows match',
Expand All @@ -46,4 +70,30 @@ void main() {
expect(response.count, 0);
},
);

test(
'maybeSingle() keeps the reported code and hint on a real error',
() async {
final postgrest = PostgrestClient(
'https://example.com',
httpClient: MultipleRowsHttpClient(),
);

await expectLater(
() => postgrest.from('users').select().maybeSingle(),
throwsA(
isA<PostgrestException>()
.having((e) => e.statusCode, 'statusCode', 406)
.having((e) => e.errorCode, 'errorCode', 'PGRST116')
.having((e) => e.hint, 'hint', 'Ask for more rows')
.having(
(e) => e.details,
'details',
'Results contain 2 rows, application/vnd.pgrst.object+json '
'requires 1 row',
),
),
);
},
);
}
46 changes: 11 additions & 35 deletions packages/storage_client/lib/src/fetch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,7 @@ class Fetch {
return StorageException(error.toString());
}

// A proxy or gateway in front of storage can answer with anything, so a
// body that is not a JSON object is surfaced as-is instead of being cast.
Map<String, dynamic>? data;
try {
final decoded = json.decode(error.body);
if (decoded is Map<String, dynamic>) {
data = decoded;
}
} on FormatException catch (_) {
// Not JSON at all.
}
final data = tryDecodeJsonObject(error.body);

if (data == null) {
_log.fine('StorageException for $url', error.body, stack);
Expand All @@ -69,12 +59,7 @@ class Fetch {
) async {
final headers = {...?options?.headers};
if (method != HttpMethod.get) {
final hasContentType = headers.keys.any(
(key) => key.toLowerCase() == 'content-type',
);
if (!hasContentType) {
headers['Content-Type'] = 'application/json';
}
setDefaultContentType(headers, 'application/json');
}

final request = http.Request(method.value, Uri.parse(url))
Expand All @@ -84,12 +69,10 @@ class Fetch {
}

_log.finest('Request: ${method.value} $url $headers');
final http.StreamedResponse streamedResponse;
if (httpClient != null) {
streamedResponse = await httpClient!.send(request);
} else {
streamedResponse = await request.send();
}
final streamedResponse = await sendRequest(
request,
httpClient: httpClient,
);
return _handleResponse(streamedResponse, options);
}

Expand Down Expand Up @@ -189,12 +172,7 @@ class Fetch {
);

// Create a fresh request for each retry attempt
final request = createRequest();

if (httpClient != null) {
return httpClient!.send(request);
}
return request.send();
return sendRequest(createRequest(), httpClient: httpClient);
},
retryIf: (error) =>
retryController?.cancelled != true &&
Expand Down Expand Up @@ -255,12 +233,10 @@ class Fetch {
..headers.addAll({...?options?.headers});

_log.finest('Request: GET (stream) $url ${request.headers}');
final http.StreamedResponse streamedResponse;
if (httpClient != null) {
streamedResponse = await httpClient!.send(request);
} else {
streamedResponse = await request.send();
}
final streamedResponse = await sendRequest(
request,
httpClient: httpClient,
);

if (!isSuccessStatusCode(streamedResponse.statusCode)) {
final response = await http.Response.fromStream(streamedResponse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,10 @@ class IcebergRestCatalog {

final http.StreamedResponse streamedResponse;
try {
streamedResponse = _httpClient != null
? await _httpClient.send(request)
: await request.send();
streamedResponse = await sendRequest(
request,
httpClient: _httpClient,
);
} catch (error) {
throw IcebergNetworkException(
'Network request failed: $error',
Expand All @@ -164,8 +165,7 @@ class IcebergRestCatalog {
return _IcebergResponse(304, response.headers, null);
}

final contentType = response.headers['content-type'] ?? '';
final isJson = contentType.contains('application/json');
final isJson = responseMediaType(response.headers) == 'application/json';
final decoded = isJson && response.body.isNotEmpty
? json.decode(response.body)
: response.body;
Expand Down
19 changes: 14 additions & 5 deletions packages/storage_client/lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -594,14 +594,23 @@ class SignedUploadURLResponse extends SignedUrl {
class StorageException extends SupabaseException {
const StorageException(super.message, {super.statusCode, super.errorCode});

/// Builds an exception from an error response body.
///
/// A JSON object is no guarantee that its fields carry the types the storage
/// API documents, since a proxy or gateway in front of it can answer with a
/// shape of its own, so every field is read defensively. [statusCode] is used
/// when the body reports none.
factory StorageException.fromJson(
Map<String, dynamic> json, [
int? statusCode,
]) => StorageException(
json['message'] as String? ?? json.toString(),
errorCode: json['error'] as String?,
statusCode: int.tryParse('${json['statusCode']}') ?? statusCode,
);
]) {
final message = json['message'];
return StorageException(
message is String ? message : json.toString(),
errorCode: json['error']?.toString(),
statusCode: int.tryParse('${json['statusCode']}') ?? statusCode,
);
}
}

class StorageRetryController {
Expand Down
11 changes: 11 additions & 0 deletions packages/storage_client/test/types_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,17 @@ void main() {
expect(exception.statusCode, 404);
});

test('fromJson tolerates unexpected field types', () {
final exception = StorageException.fromJson({
'message': {'nested': 'object'},
'error': 502,
}, 502);

expect(exception.message, '{message: {nested: object}, error: 502}');
expect(exception.errorCode, '502');
expect(exception.statusCode, 502);
});

test(
'fromJson falls back to the fallback status code and stringified body',
() {
Expand Down
6 changes: 3 additions & 3 deletions packages/supabase_common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ packages (`gotrue`, `postgrest`, `realtime_client`, `storage_client`,

This package holds code that would otherwise be duplicated across those
packages: the `SupabaseException` base class the auth, postgrest, storage and
functions exceptions extend, the `X-Client-Info` header builder, platform
detection, a small replay stream subject, base64url/PKCE helpers and a few
other primitives.
functions exceptions extend, the shared `HttpMethod` enum and HTTP request
helpers, the `X-Client-Info` header builder, platform detection, a small replay
stream subject, base64url/PKCE helpers and a few other primitives.
Loading
Loading