From 50b6952abed2f0458837c1abb193db14a125904f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 11:58:29 +0200 Subject: [PATCH 1/4] refactor: share the HTTP request pieces between the fetch layers The four fetch layers each rebuilt the same primitives. They now come from `supabase_common`: - `sendRequest`, which sends over the caller's `Client` or a one-off one. Used by functions, storage's three send sites and the Iceberg catalog. - `headerValue`, a case-insensitive header lookup. - `setDefaultContentType`, which only sets `Content-Type` when the caller did not, replacing storage's and functions' own case-insensitive checks. - `responseMediaType`, replacing functions' manual parse of the response content type and the Iceberg catalog's `contains('application/json')`. - `tryDecodeJsonObject`, for error bodies that a proxy or gateway may return as something other than a JSON object. Two bugs fall out of the last one: - Storage cast the decoded error body to `Map` inside a `try`/`on FormatException`, so a JSON body that parsed but was not an object (an array, say) escaped as a `TypeError` instead of a `StorageException`. - Postgrest wrapped both the decode and the `maybeSingle` handling in one `try`/`catch (_)`, so the typed exception the handler rethrows for a real error was swallowed and replaced by a generic one built from the raw body, losing the reported code, details and hint. Postgrest's own send site keeps managing its `Client` explicitly, since it has to close a client it created even when the response body fails midway, which `BaseRequest.send` does not do. Part of #1572 (tier 3), under the v3 umbrella #1278. --- .../lib/src/functions_client.dart | 17 +-- .../postgrest/lib/src/postgrest_builder.dart | 18 ++-- .../postgrest/test/maybe_single_test.dart | 47 ++++++++ packages/storage_client/lib/src/fetch.dart | 46 ++------ .../lib/src/iceberg/iceberg_rest_catalog.dart | 10 +- packages/supabase_common/README.md | 6 +- packages/supabase_common/lib/src/http.dart | 63 +++++++++++ .../supabase_common/lib/supabase_common.dart | 1 + packages/supabase_common/pubspec.yaml | 1 + packages/supabase_common/test/http_test.dart | 101 ++++++++++++++++++ 10 files changed, 247 insertions(+), 63 deletions(-) create mode 100644 packages/supabase_common/lib/src/http.dart create mode 100644 packages/supabase_common/test/http_test.dart diff --git a/packages/functions_client/lib/src/functions_client.dart b/packages/functions_client/lib/src/functions_client.dart index 6c2665e58..a133906ea 100644 --- a/packages/functions_client/lib/src/functions_client.dart +++ b/packages/functions_client/lib/src/functions_client.dart @@ -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) { @@ -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 = diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index 1ceb2362b..e6d3f47ab 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -541,8 +541,16 @@ class PostgrestBuilder implements Future { } PostgrestException error; if (response.request!.method != HttpMethod.head.value) { - try { - final errorJson = jsonDecode(response.body) as Map; + // 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, @@ -553,12 +561,6 @@ class PostgrestBuilder implements Future { if (_maybeSingle) { return _handleMaybeSingleError(response, error); } - } catch (_) { - error = PostgrestException( - message: response.body, - statusCode: response.statusCode, - details: response.reasonPhrase, - ); } } else { error = PostgrestException( diff --git a/packages/postgrest/test/maybe_single_test.dart b/packages/postgrest/test/maybe_single_test.dart index 2cf8b6d0e..29f30b7ae 100644 --- a/packages/postgrest/test/maybe_single_test.dart +++ b/packages/postgrest/test/maybe_single_test.dart @@ -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 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', @@ -46,4 +70,27 @@ void main() { expect(response.count, 0); }, ); + + test('maybeSingle() keeps the reported code and hint on a real error', () { + final postgrest = PostgrestClient( + 'https://example.com', + httpClient: MultipleRowsHttpClient(), + ); + + expect( + postgrest.from('users').select().maybeSingle(), + throwsA( + isA() + .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', + ), + ), + ); + }); } diff --git a/packages/storage_client/lib/src/fetch.dart b/packages/storage_client/lib/src/fetch.dart index 08f201282..c285cfd56 100644 --- a/packages/storage_client/lib/src/fetch.dart +++ b/packages/storage_client/lib/src/fetch.dart @@ -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? data; - try { - final decoded = json.decode(error.body); - if (decoded is Map) { - 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); @@ -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)) @@ -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); } @@ -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 && @@ -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); diff --git a/packages/storage_client/lib/src/iceberg/iceberg_rest_catalog.dart b/packages/storage_client/lib/src/iceberg/iceberg_rest_catalog.dart index 01e5a2de4..41f483c1e 100644 --- a/packages/storage_client/lib/src/iceberg/iceberg_rest_catalog.dart +++ b/packages/storage_client/lib/src/iceberg/iceberg_rest_catalog.dart @@ -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', @@ -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; diff --git a/packages/supabase_common/README.md b/packages/supabase_common/README.md index 8cd13b32a..853f7e619 100644 --- a/packages/supabase_common/README.md +++ b/packages/supabase_common/README.md @@ -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. diff --git a/packages/supabase_common/lib/src/http.dart b/packages/supabase_common/lib/src/http.dart new file mode 100644 index 000000000..b1edfe5fa --- /dev/null +++ b/packages/supabase_common/lib/src/http.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +/// Sends [request] over [httpClient], or over a one-off client when no client +/// was provided. +/// +/// Every Supabase client takes an optional [Client] so callers can plug in +/// their own transport, and falls back to the default one otherwise. +Future sendRequest( + BaseRequest request, { + Client? httpClient, +}) => httpClient?.send(request) ?? request.send(); + +/// The value of the [name] header, matched case insensitively. +/// +/// HTTP header names are case insensitive, and while `package:http` lowercases +/// the names of received headers, headers assembled by the client packages can +/// use any casing. +String? headerValue(Map headers, String name) { + final lowerCaseName = name.toLowerCase(); + for (final entry in headers.entries) { + if (entry.key.toLowerCase() == lowerCaseName) { + return entry.value; + } + } + return null; +} + +/// Sets `Content-Type` to [value] unless [headers] already carries one. +/// +/// Requests whose body the caller controls must not have an explicitly passed +/// content type overwritten. +void setDefaultContentType(Map headers, String value) { + if (headerValue(headers, 'content-type') == null) { + headers['Content-Type'] = value; + } +} + +/// The media type of a response, lowercased and without any parameters, so +/// `Content-Type: application/json; charset=utf-8` becomes `application/json`. +/// +/// Returns `null` when the response carries no content type. +String? responseMediaType(Map headers) => + headerValue(headers, 'content-type')?.split(';').first.trim().toLowerCase(); + +/// Decodes [body] as a JSON object, or returns `null` when it is empty, is not +/// valid JSON, or is valid JSON that is not an object. +/// +/// Error responses are the main use: a service is expected to describe the +/// failure in a JSON object, but a proxy or gateway in front of it can return +/// anything at all, so decoding must not throw. +Map? tryDecodeJsonObject(String body) { + if (body.isEmpty) { + return null; + } + try { + final decoded = json.decode(body); + return decoded is Map ? decoded : null; + } on FormatException { + return null; + } +} diff --git a/packages/supabase_common/lib/supabase_common.dart b/packages/supabase_common/lib/supabase_common.dart index 30b6db2f1..77552cb87 100644 --- a/packages/supabase_common/lib/supabase_common.dart +++ b/packages/supabase_common/lib/supabase_common.dart @@ -9,6 +9,7 @@ export 'src/backoff.dart'; export 'src/base64url.dart'; export 'src/client_info.dart'; export 'src/fetch_options.dart'; +export 'src/http.dart'; export 'src/http_method.dart'; export 'src/http_status.dart'; export 'src/pkce.dart'; diff --git a/packages/supabase_common/pubspec.yaml b/packages/supabase_common/pubspec.yaml index c18d3c823..5c958f4a8 100644 --- a/packages/supabase_common/pubspec.yaml +++ b/packages/supabase_common/pubspec.yaml @@ -16,6 +16,7 @@ resolution: workspace dependencies: crypto: ^3.0.7 + http: ^1.6.0 meta: ^1.16.0 dev_dependencies: diff --git a/packages/supabase_common/test/http_test.dart b/packages/supabase_common/test/http_test.dart new file mode 100644 index 000000000..e422102ea --- /dev/null +++ b/packages/supabase_common/test/http_test.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +class _RecordingClient extends BaseClient { + BaseRequest? sentRequest; + + @override + Future send(BaseRequest request) async { + sentRequest = request; + return StreamedResponse( + Stream.value(utf8.encode('sent by the custom client')), + 200, + request: request, + ); + } +} + +void main() { + group('sendRequest', () { + test('sends over the given client', () async { + final httpClient = _RecordingClient(); + final request = Request('GET', Uri.parse('http://localhost/things')); + + final response = await sendRequest(request, httpClient: httpClient); + + expect(httpClient.sentRequest, same(request)); + expect( + await response.stream.bytesToString(), + 'sent by the custom client', + ); + }); + }); + + group('headerValue', () { + test('matches the name case insensitively', () { + const headers = {'Content-Type': 'application/json'}; + + expect(headerValue(headers, 'content-type'), 'application/json'); + expect(headerValue(headers, 'CONTENT-TYPE'), 'application/json'); + }); + + test('returns null when the header is absent', () { + expect(headerValue(const {}, 'content-type'), isNull); + }); + }); + + group('setDefaultContentType', () { + test('sets the content type when there is none', () { + final headers = {}; + + setDefaultContentType(headers, 'application/json'); + + expect(headers, {'Content-Type': 'application/json'}); + }); + + test('keeps a content type set under any casing', () { + final headers = {'content-type': 'text/csv'}; + + setDefaultContentType(headers, 'application/json'); + + expect(headers, {'content-type': 'text/csv'}); + }); + }); + + group('responseMediaType', () { + test('drops parameters and lowercases the type', () { + expect( + responseMediaType(const { + 'content-type': 'Application/JSON; charset=utf-8', + }), + 'application/json', + ); + }); + + test('returns null when there is no content type', () { + expect(responseMediaType(const {}), isNull); + }); + }); + + group('tryDecodeJsonObject', () { + test('decodes a JSON object', () { + expect(tryDecodeJsonObject('{"message":"boom"}'), {'message': 'boom'}); + }); + + test('returns null for an empty body', () { + expect(tryDecodeJsonObject(''), isNull); + }); + + test('returns null for a body that is not JSON', () { + expect(tryDecodeJsonObject('502 Bad Gateway'), isNull); + }); + + test('returns null for JSON that is not an object', () { + expect(tryDecodeJsonObject('["boom"]'), isNull); + expect(tryDecodeJsonObject('42'), isNull); + }); + }); +} From 2894a6da728bc5ea4fa15e497897fd28e1a4b0cc Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 12:05:28 +0200 Subject: [PATCH 2/4] chore: satisfy DCM in the shared fetch pieces --- .../postgrest/test/maybe_single_test.dart | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/postgrest/test/maybe_single_test.dart b/packages/postgrest/test/maybe_single_test.dart index 29f30b7ae..e742e2b5f 100644 --- a/packages/postgrest/test/maybe_single_test.dart +++ b/packages/postgrest/test/maybe_single_test.dart @@ -71,26 +71,29 @@ void main() { }, ); - test('maybeSingle() keeps the reported code and hint on a real error', () { - final postgrest = PostgrestClient( - 'https://example.com', - httpClient: MultipleRowsHttpClient(), - ); + test( + 'maybeSingle() keeps the reported code and hint on a real error', + () async { + final postgrest = PostgrestClient( + 'https://example.com', + httpClient: MultipleRowsHttpClient(), + ); - expect( - postgrest.from('users').select().maybeSingle(), - throwsA( - isA() - .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', - ), - ), - ); - }); + await expectLater( + () => postgrest.from('users').select().maybeSingle(), + throwsA( + isA() + .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', + ), + ), + ); + }, + ); } From 57ca9bf153ffbd896d3bd623aacfc05fe11f1786 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 13:23:59 +0200 Subject: [PATCH 3/4] fix: keep a malformed JSON error body from escaping as a TypeError Removing the catch-all around the postgrest error decode exposed the next layer of the same problem: `PostgrestException.fromJson` cast `code` and `hint` to `String` and required a `String` message, so an error body that is a JSON object with different field types, say the `{"code": 502, "message": "Bad gateway"}` a gateway returns, threw a `TypeError` out of the builder instead of surfacing as a `PostgrestException`. Both factories now read every field defensively, so any JSON object produces an exception. `StorageException.fromJson` had the same casts and the same exposure. Part of #1572 (tier 3), under the v3 umbrella #1278. --- packages/postgrest/lib/src/types.dart | 15 ++++++++++++--- packages/postgrest/test/basic_test.dart | 13 +++++++++++++ .../postgrest/test/custom_http_client.dart | 13 +++++++++++++ packages/storage_client/lib/src/types.dart | 19 ++++++++++++++----- packages/storage_client/test/types_test.dart | 11 +++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/postgrest/lib/src/types.dart b/packages/postgrest/lib/src/types.dart index 3a8dfcf42..79d50c346 100644 --- a/packages/postgrest/lib/src/types.dart +++ b/packages/postgrest/lib/src/types.dart @@ -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 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(), ); } diff --git a/packages/postgrest/test/basic_test.dart b/packages/postgrest/test/basic_test.dart index ca12e2a03..440202b2f 100644 --- a/packages/postgrest/test/basic_test.dart +++ b/packages/postgrest/test/basic_test.dart @@ -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() + .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 diff --git a/packages/postgrest/test/custom_http_client.dart b/packages/postgrest/test/custom_http_client.dart index b203a3f81..b97c7e24d 100644 --- a/packages/postgrest/test/custom_http_client.dart +++ b/packages/postgrest/test/custom_http_client.dart @@ -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!), diff --git a/packages/storage_client/lib/src/types.dart b/packages/storage_client/lib/src/types.dart index cc1ebb9f0..e843c3508 100644 --- a/packages/storage_client/lib/src/types.dart +++ b/packages/storage_client/lib/src/types.dart @@ -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 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 { diff --git a/packages/storage_client/test/types_test.dart b/packages/storage_client/test/types_test.dart index ac6d082d3..f073a197b 100644 --- a/packages/storage_client/test/types_test.dart +++ b/packages/storage_client/test/types_test.dart @@ -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', () { From c3cfc32cdce35c8f380810445094c325d6575f13 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 15:49:28 +0200 Subject: [PATCH 4/4] test(supabase_common): cover the remaining non-object JSON bodies --- packages/supabase_common/test/http_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/supabase_common/test/http_test.dart b/packages/supabase_common/test/http_test.dart index e422102ea..e99f001d2 100644 --- a/packages/supabase_common/test/http_test.dart +++ b/packages/supabase_common/test/http_test.dart @@ -95,7 +95,9 @@ void main() { test('returns null for JSON that is not an object', () { expect(tryDecodeJsonObject('["boom"]'), isNull); + expect(tryDecodeJsonObject('"boom"'), isNull); expect(tryDecodeJsonObject('42'), isNull); + expect(tryDecodeJsonObject('null'), isNull); }); }); }