Skip to content

refactor!: split every service exception into a client and an api base - #1644

Open
spydon wants to merge 14 commits into
mainfrom
breaking/supabase-exception-base
Open

refactor!: split every service exception into a client and an api base#1644
spydon wants to merge 14 commits into
mainfrom
breaking/supabase-exception-base

Conversation

@spydon

@spydon spydon commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Tier 3 of #1572, first of four PRs. Under the v3 umbrella #1278.

What

AuthException, StorageException, PostgrestException and FunctionException each reimplemented the same message plus status shape under different field names and types. They now share one hierarchy in supabase_common:

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

mixin SupabaseApiException on SupabaseException {
  int get statusCode;
}

A plain SupabaseException is a failure the client raised on its own, before or without a request. A SupabaseApiException is a failure a service reported, so statusCode is a non-nullable int that exists exactly when a service answered, and the layer a failure came from is visible from its type alone. Both are re-exported from every package, so one catch handles a failure from any service:

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

The two level split came out of @Vinzent03's review. A single base handed every client raised failure a statusCode and an errorCode that were always null, and storage went further than that: a request that never reached the server produced StorageException(statusCode: 'ClientException'), the exception's own type name in the status field.

SupabaseApiException is a mixin rather than a second base class because the two axes are orthogonal. AuthApiException has to be an AuthException, since that is what callers catch, and also the cross service type for "a service answered". Dart gives you one superclass, so one of the two has to be a mixin or an interface, and neither can hold state. That is why each API exception declares statusCode itself.

The hierarchy

Package Client raised Service reported
gotrue AuthException, AuthPKCEGrantCodeExchangeError, AuthSessionMissingException, AuthInvalidJwtException, AuthUnknownException, AuthRetryableFetchException AuthApiException, AuthWeakPasswordException, AuthRetryableApiException (new)
postgrest none, every failure is a response PostgrestApiException
storage_client StorageException StorageApiException (new)
functions_client FunctionException, FunctionsFetchException FunctionsApiException, FunctionsRelayException

PostgrestApiException mixes SupabaseApiException in directly instead of gaining a subclass, since every Postgrest failure comes from a response. It keeps the Api part of the name so it lines up with the other packages, which leaves the plain PostgrestException name free for a client raised base if one is ever needed.

Breaking changes

Before After
AuthException.statusCode (String?) AuthApiException.statusCode (int, required)
AuthException.code AuthException.errorCode
AuthSessionMissingException.statusCode == '400' no status, errorCode is session_missing
AuthInvalidJwtException.statusCode == '400' no status, errorCode is invalid_jwt
AuthWeakPasswordException extends AuthException extends AuthApiException
AuthRetryableFetchException.statusCode gone; a 5xx is an AuthRetryableApiException, which carries it
AuthUnknownException.statusCode gone; read it from originalError
StorageException.statusCode (String?) StorageApiException.statusCode (int, required)
StorageException.error errorCode
StorageException.fromJson(json, '404') StorageApiException.fromJson(json, 404)
PostgrestException PostgrestApiException
PostgrestException.code (PostgREST code, or the HTTP status when the body was not JSON) errorCode (PostgREST or PostgreSQL code only) and statusCode (HTTP status, non-nullable)
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 (int, required)
FunctionException.reasonPhrase folded into message
FunctionsFetchException.status == 0 no status at all
FunctionResponse.status FunctionResponse.statusCode

Notes on the less mechanical ones:

  • Postgrest no longer overloads code. It used to stuff the HTTP status into code when the error body was not JSON, so code was sometimes PGRST116 and sometimes 409. The status now has its own field and errorCode is only ever a PostgREST or PostgreSQL code. A duplicate key violation reads as statusCode: 409, errorCode: '23505'.
  • Two auth exceptions stop fabricating a status. AuthSessionMissingException and AuthInvalidJwtException are raised by the client, never by the service, so the 400 they reported was invented. They report an error code instead.
  • AuthRetryableFetchException split in two. It was thrown both for a transport failure, which has no status, and for a 5xx, which has one. It now covers only the transport case, and AuthRetryableApiException extends it for a 5xx. e is AuthRetryableFetchException still catches both, so the retry and refresh paths in GoTrueClient are unchanged.
  • Functions gained a message. FunctionException had no message, only status, details and reasonPhrase. The response's reason phrase becomes the message, and when the response carries none, as over HTTP/2, each subtype falls back to its own default, matching supabase-js: 'Failed to send a request to the Edge Function', 'Relay error invoking the Edge Function', 'Edge Function returned a non-2xx status code'. The response body is still in details.
  • FunctionsHttpException is gone. It and FunctionsApiException both meant "the Edge Function answered with a non-2xx status", which is the definition of an api exception, so FunctionsApiException took over the name and the default message. FunctionsRelayException stays, because a relay error means the function may never have run.
  • FunctionResponse.status is renamed too. With the exceptions in that package reporting statusCode, the successful response was the only thing left calling it status. It also lines up with http.Response.statusCode.
  • AuthException compares the runtime type. statusCode moved to the subclasses, so equality had to move with it. The base compares the concrete type, message and error code, and the API subclasses add the status, which keeps equality symmetric across the hierarchy.

getSessionFromUrl also had to change how it reads an error callback: error_code holds either a numeric status (older links) or a code such as otp_expired. The numeric form now throws an AuthApiException and anything else a plain AuthException whose errorCode falls back to the error parameter. Both shapes are covered by tests.

Every auth exception subclass used to repeat the same toString; the shared classes now print the concrete runtime type, so only the subtypes with extra fields (originalError, reasons, details, hint) override it.

A bug this surfaces

Fetch._handleError in storage cast the decoded error body to Map<String, dynamic> inside a try/on FormatException. A body that parses as JSON but is not an object, for example the ["upstream connect error"] a gateway can return, throws a TypeError on that cast, which the on FormatException does not catch, so it escaped instead of surfacing as a StorageException. Fixed here, with a regression test; #1647 later replaces the inline guard with the shared tryDecodeJsonObject helper.

Testing

dart analyze, dcm analyze and dart format are clean. Full test suites pass for gotrue, postgrest, storage_client, functions_client, realtime_client, supabase, supabase_common (against the local stack) and supabase_flutter, plus the examples analyzer. The capability matrix symbol, drift and schema checks pass with the new symbols registered.

auth_exception_test.dart loses more lines than it gains, which is deliberate. It had grown a large number of tests that could not fail: constructor round trips reading back the values just passed in, isA checks the compiler already guarantees, a whole Error handling scenarios group restating other tests with different string literals, and one test named after equality that compared field by field and never invoked ==. What is left covers behaviour that can break, and its toString assertions are exact matches rather than contains. The real coverage for this change is in fetch_test.dart, which drives HTTP responses through the client and asserts the exception that comes out.

Migration

MIGRATION.md gains a section for the new hierarchy: the shape, the full rename table, and the one case that needs a code change rather than a rename, which is reading a status off a per-service base.

Out of scope

RealtimeSubscribeException has no message at all, only a RealtimeSubscribeStatus and details, and it reports a channel subscription outcome rather than a request failure. It does not fit the base without inventing a message, so it keeps its own shape.

The sealed IcebergException is a closer fit and is worth migrating, in its own PR: it already has message, a non-nullable statusCode, a type that maps onto errorCode, and details. It also carries the same statusCode == 0 sentinel for a network failure that this PR removed from FunctionsFetchException, so it wants the same client and api split rather than a straight rename.

Making FunctionException sealed is tracked separately in #1550.

@spydon
spydon requested a review from a team as a code owner August 5, 2026 09:29
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds SupabaseException in the shared package. It migrates Functions, GoTrue, PostgREST, and Storage exceptions to use statusCode and errorCode. It also updates parsing, exports, examples, SDK compliance, and tests.

Changes

Shared exception contract

Layer / File(s) Summary
Shared exception base
packages/supabase_common/...
Adds and exports SupabaseException with message, nullable statusCode, nullable errorCode, and formatted toString() output.

Authentication exceptions

Layer / File(s) Summary
GoTrue exception migration
packages/gotrue/lib/...
Migrates authentication exceptions to SupabaseException, renames code to errorCode, and uses integer HTTP status codes.
GoTrue validation and integration updates
packages/gotrue/test/..., packages/supabase_flutter/test/deep_link_test.dart
Updates authentication assertions, equality checks, string output checks, OAuth error matching, and named error-code coverage.

Functions exceptions

Layer / File(s) Summary
Functions exception migration
packages/functions_client/lib/...
Migrates function exceptions to SupabaseException, uses statusCode and message, and represents transport failures with no response status.
Functions examples, compliance, and tests
packages/functions_client/test/..., packages/functions_client/example/..., examples/edge_functions/..., sdk-compliance.yaml
Updates function assertions, fallback messages, examples, comments, and invocation symbol mappings.

PostgREST exceptions

Layer / File(s) Summary
PostgREST exception migration
packages/postgrest/lib/..., packages/postgrest/example/...
Migrates PostgrestException to SupabaseException and separates HTTP statusCode from PostgreSQL errorCode.
PostgREST validation
packages/postgrest/test/...
Updates response, maybeSingle, RPC, malformed-response, and upsert assertions.

Storage exceptions

Layer / File(s) Summary
Storage exception migration
packages/storage_client/lib/...
Migrates StorageException to SupabaseException, preserves integer HTTP status codes, and maps JSON errors to errorCode.
Storage validation
packages/storage_client/test/...
Updates storage status-code, error-code, hierarchy, parsing, and cleanup assertions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested labels: storage

Suggested reviewers: tr00d

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title is misleading because the changes introduce one shared SupabaseException base class, not separate client and API bases. Update the title to describe the shared SupabaseException base class and the related service-exception refactor.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch breaking/supabase-exception-base

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
examples/edge_functions/lib/main.dart (1)

363-367: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle transport failures without rendering a null status.

When a request fails before a response, statusCode is null. The current fallback displays Function failed with status null. Use error.message for that case.

Proposed fix
-        : 'Function failed with status ${error.statusCode}';
+        : error.statusCode == null
+            ? error.message
+            : 'Function failed with status ${error.statusCode}';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/edge_functions/lib/main.dart` around lines 363 - 367, Update the
FunctionException fallback in the error-handling flow to use error.message when
error.statusCode is null, avoiding a rendered “status null” message. Preserve
the existing details['error'] handling and continue using the status-based
fallback when a status code is available.
packages/postgrest/lib/src/types.dart (1)

27-49: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve normalized fields in PostgrestException.fromJson().

toJson() writes statusCode and errorCode, but the constructor only passes those fields in through separate arguments. When an app serializes an example exception, then deserializes the JSON with PostgrestException.fromJson(...), statusCode and errorCode disappear. Add fallback reads for json['statusCode'] and json['errorCode'], and cover this in a round-trip test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/postgrest/lib/src/types.dart` around lines 27 - 49, Update
PostgrestException.fromJson() to use json['statusCode'] and json['errorCode'] as
fallbacks when the statusCode and errorCode arguments are absent, preserving
explicitly supplied arguments. Add a round-trip test covering toJson() followed
by fromJson() and verifying both normalized fields remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/storage_client/lib/src/fetch.dart`:
- Around line 40-50: Update the error-body parsing flow around
StorageException.fromJson so valid non-object JSON values, including arrays,
strings, and null, are normalized to a StorageException containing the raw
error.body instead of triggering an uncaught TypeError; preserve the existing
object parsing and FormatException behavior, and add a regression test covering
a non-object JSON HTTP error response.

In `@packages/supabase_common/lib/src/supabase_exception.dart`:
- Around line 3-5: Correct the exception-contract documentation so it applies
only to exceptions extending SupabaseException, rather than every service
exception. Update the documentation comment in
packages/supabase_common/lib/src/supabase_exception.dart at lines 3-5 and the
inheritance statement in packages/supabase_common/README.md at lines 14-16;
explicitly scope the README statement to the migrated exception types and leave
RealtimeSubscribeException and IcebergException excluded.

---

Outside diff comments:
In `@examples/edge_functions/lib/main.dart`:
- Around line 363-367: Update the FunctionException fallback in the
error-handling flow to use error.message when error.statusCode is null, avoiding
a rendered “status null” message. Preserve the existing details['error']
handling and continue using the status-based fallback when a status code is
available.

In `@packages/postgrest/lib/src/types.dart`:
- Around line 27-49: Update PostgrestException.fromJson() to use
json['statusCode'] and json['errorCode'] as fallbacks when the statusCode and
errorCode arguments are absent, preserving explicitly supplied arguments. Add a
round-trip test covering toJson() followed by fromJson() and verifying both
normalized fields remain unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1746a526-08a7-4060-8c83-db9eeecb782a

📥 Commits

Reviewing files that changed from the base of the PR and between 5be5a16 and 192d159.

📒 Files selected for processing (39)
  • examples/edge_functions/integration_test/functions_test.dart
  • examples/edge_functions/integration_test/invoke_test.dart
  • examples/edge_functions/lib/main.dart
  • packages/functions_client/example/functions_dart_example.dart
  • packages/functions_client/lib/functions_client.dart
  • packages/functions_client/lib/src/functions_client.dart
  • packages/functions_client/lib/src/types.dart
  • packages/functions_client/test/functions_dart_test.dart
  • packages/gotrue/lib/gotrue.dart
  • packages/gotrue/lib/src/fetch.dart
  • packages/gotrue/lib/src/gotrue_client.dart
  • packages/gotrue/lib/src/types/auth_exception.dart
  • packages/gotrue/test/client_test.dart
  • packages/gotrue/test/fetch_test.dart
  • packages/gotrue/test/otp_mock_test.dart
  • packages/gotrue/test/passkey_test.dart
  • packages/gotrue/test/provider_test.dart
  • packages/gotrue/test/src/gotrue_oauth_api_test.dart
  • packages/gotrue/test/src/types/auth_exception_test.dart
  • packages/gotrue/test/web3_auth_test.dart
  • packages/postgrest/example/main.dart
  • packages/postgrest/lib/postgrest.dart
  • packages/postgrest/lib/src/postgrest_builder.dart
  • packages/postgrest/lib/src/types.dart
  • packages/postgrest/test/basic_test.dart
  • packages/postgrest/test/transforms_test.dart
  • packages/postgrest/test/upsert_test.dart
  • packages/storage_client/lib/src/fetch.dart
  • packages/storage_client/lib/src/storage_file_api.dart
  • packages/storage_client/lib/src/types.dart
  • packages/storage_client/lib/storage_client.dart
  • packages/storage_client/test/basic_test.dart
  • packages/storage_client/test/client_test.dart
  • packages/storage_client/test/types_test.dart
  • packages/supabase_common/README.md
  • packages/supabase_common/lib/src/supabase_exception.dart
  • packages/supabase_common/lib/supabase_common.dart
  • packages/supabase_common/test/supabase_exception_test.dart
  • packages/supabase_flutter/test/deep_link_test.dart

Comment thread packages/storage_client/lib/src/fetch.dart Outdated
Comment thread packages/supabase_common/lib/src/supabase_exception.dart Outdated
@spydon spydon changed the title breaking: give every service exception a shared SupabaseException base refactor!: give every service exception a shared SupabaseException base Aug 5, 2026
@Vinzent03

Copy link
Copy Markdown
Collaborator

We have cases where an exception is thrown with only a message for some bad state in the client e.g.

if (accessToken == null) {
throw AuthException('No access_token detected.');
}
if (expiresIn == null) {
throw AuthException('No expires_in detected.');
}
if (refreshToken == null) {
throw AuthException('No refresh_token detected.');
}
if (tokenType == null) {
throw AuthException('No token_type detected.');

So these cases have no status or error code. Currently they are null in these cases. Error codes and status codes are only present for http/supabase api calls. So I'm wondering if it makes sense to separate those api exceptions from these client only exceptions and maybe even have a class with status code being non-nullable. Such that we have a SupabaseApiException with error and status code and SupabaseException with message only. This could make it easier to know on first sight from which layer or part an exception is coming from.

@spydon

spydon commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

We have cases where an exception is thrown with only a message for some bad state in the client e.g.

if (accessToken == null) {
throw AuthException('No access_token detected.');
}
if (expiresIn == null) {
throw AuthException('No expires_in detected.');
}
if (refreshToken == null) {
throw AuthException('No refresh_token detected.');
}
if (tokenType == null) {
throw AuthException('No token_type detected.');

So these cases have no status or error code. Currently they are null in these cases. Error codes and status codes are only present for http/supabase api calls. So I'm wondering if it makes sense to separate those api exceptions from these client only exceptions and maybe even have a class with status code being non-nullable. Such that we have a SupabaseApiException with error and status code and SupabaseException with message only. This could make it easier to know on first sight from which layer or part an exception is coming from.

Sounds like a good idea!

@spydon
spydon force-pushed the breaking/supabase-exception-base branch from 68959ca to 6667939 Compare August 12, 2026 08:21
@spydon

spydon commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@Vinzent03 what do you think about this solution?

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

  const SupabaseException(this.message, {this.errorCode});
}

mixin SupabaseApiException on SupabaseException {
  int get statusCode;
}

@spydon spydon changed the title refactor!: give every service exception a shared SupabaseException base refactor!: split every service exception into a client and an api base Aug 12, 2026
@Vinzent03

Vinzent03 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

I think I really like the new style. But some small things:

  • What is the real difference between FunctionsApiException and a FunctionsHttpException. I can see the reason for the relay exception, but I'm wondering if the http on can just be the FunctionsApiException. Primarily because it is not very aligned with the other exception classes from the other packages. The definition of an api exception is that it comes from a http response with no 2xx status code so I feel like the api exception one is enough.
  • I'm thinking whether the PostgrestException should be called PostgrestApiException as well like the others such that it is more aligned and we are freer to introduce an PostgrestException in the future.

Also may I ask for the reason of excluding the realtimesubscribe exception and iceberg here? Haven't looked into it really, but just wondering.

@spydon

spydon commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

I think I really like the new style. But some small things:

* What is the real difference between `FunctionsApiException` and a `FunctionsHttpException`. I can see the reason for the relay exception, but I'm wondering if the http on can just be the `FunctionsApiException`. Primarily because it is not very aligned with the other exception classes from the other packages. The definition of an api exception is that it comes from a http response with no 2xx status code so I feel like the api exception one is enough.

* I'm thinking whether the `PostgrestException` should be called `PostgrestApiException` as well like the others such that it is more aligned and we are freer to introduce an `PostgrestException` in the future.

Sounds like good suggestions, I'll fix those up!

Also may I ask for the reason of excluding the realtimesubscribe exception and iceberg here? Haven't looked into it really, but just wondering.

Because they have zero overlap with the other exception structure.

EDIT:
Indeed much cleaner with just FunctionsApiException!

EDIT2:
iceberg seems like it would be worth doing after all, doing that in stacked follow-up PR though.

spydon added 7 commits August 12, 2026 17:29
`AuthException`, `StorageException`, `PostgrestException` and
`FunctionException` each reimplemented the same message plus status shape
under different field names and types. They now extend a single
`SupabaseException` in `supabase_common`, so `on SupabaseException` catches
a failure from any service.

Reconciling the four shapes means:

- `statusCode` is an `int?` everywhere. It was a `String?` in auth and
  storage, and `FunctionException.status` (an `int`) in functions.
- The service specific code is `errorCode` everywhere: `AuthException.code`,
  `StorageException.error` and `PostgrestException.code` are gone.
  `PostgrestException.errorCode` now only holds the PostgREST/PostgreSQL
  code, since the HTTP status has its own field.
- `PostgrestException.fromJson` takes `statusCode` instead of `code`.
- `FunctionException` carries a `message` like the other exceptions.
  `reasonPhrase` is gone: the response's reason phrase becomes the message,
  falling back to a per-subtype default when the response has none.
- `FunctionsFetchException.statusCode` is `null` instead of `0`, since no
  response reached the client.
- Auth exception subclasses no longer each repeat `toString`; the base
  prints the concrete runtime type.

Part of #1572 (tier 3), under the v3 umbrella #1278.
The exceptions in this package now carry the HTTP status as `statusCode`,
so the successful response reporting it as `status` was the odd one out.
It also lines up with `http.Response.statusCode`.
…ypes

The base class doc and the README claimed every service exception extends
`SupabaseException`, but `RealtimeSubscribeException` and `IcebergException`
deliberately keep shapes of their own.
`_handleError` cast the decoded error body to `Map<String, dynamic>` inside a
`try`/`on FormatException`. A body that parses as JSON but is not an object,
for example the `["upstream connect error"]` a gateway can return, throws a
`TypeError` on that cast, which the `on FormatException` does not catch, so it
escaped instead of surfacing as a `StorageException`.

The non-response branch also moved up front, which drops a level of nesting.
spydon added 7 commits August 12, 2026 17:29
`SupabaseException` reported a `statusCode` and an `errorCode` that were
always null for the failures the client raises itself, such as a missing
session or a redirect url without an access token. The layer a failure came
from was not visible from its type.

`SupabaseException` now carries only the message and the error code, and a
new `SupabaseApiException` mixin adds the response's `statusCode` as a
non-nullable `int`. Catching it handles any failure a service answered with,
whichever service that was:

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

Per package:

- `AuthException` is message plus error code. `AuthApiException` carries the
  status, and `AuthWeakPasswordException` now extends it. Both
  `AuthRetryableFetchException` and `AuthUnknownException` straddle the two
  layers, so they keep a nullable `statusCode` of their own.
- `AuthSessionMissingException` and `AuthInvalidJwtException` no longer
  report a fabricated `400`; they report `session_missing` and `invalid_jwt`.
- `getSessionFromUrl` throws an `AuthApiException` when `error_code` is a
  numeric status and a plain `AuthException` when it is a code such as
  `otp_expired`.
- `StorageApiException` is new and holds what a storage response reported,
  including `fromJson`, which now takes a non-nullable status code.
- `PostgrestException` is always a response, so it mixes the api exception in
  directly and its `statusCode` is required.
- `FunctionsApiException` is new and sits between `FunctionException` and the
  relay/http subtypes. `FunctionsFetchException` has no status at all rather
  than a null one.

Part of #1572 (tier 3), under the v3 umbrella #1278.
… unknown exceptions

`AuthRetryableFetchException` and `AuthUnknownException` each declared a
nullable `statusCode` of their own, so the auth hierarchy had three
`statusCode` members at two different nullabilities while `AuthException`
itself had none. Both fields were derived rather than load bearing:

- `AuthRetryableFetchException` covers a transport failure and a 5xx alike, so
  it could never promise a status. When the service did answer, the message
  already carries what it said, falling back to the reason phrase or
  `HTTP <status>`.
- `AuthUnknownException` computed the status from `originalError`, which it
  exposes, so callers can read it from the response directly.

Neither is read anywhere in the client; the retry path only tests the type.
`statusCode` now means one thing across every package: a non-nullable `int`
that exists exactly when the exception is a `SupabaseApiException`.
…iException

Dropping `statusCode` from `AuthRetryableFetchException` cost the status on a
retryable failure that the service did answer. Two of the three throw sites
put it in the message via `_getStatusMessage`, but the third takes the
service's own text from a JSON error body, so the status was unreachable.

`AuthRetryableApiException` extends `AuthRetryableFetchException` and mixes in
`SupabaseApiException`, so the status is back with no nullable field:

- `AuthRetryableFetchException` is the transport case, where the request never
  reached the service and there is no status to report.
- `AuthRetryableApiException` is a 5xx the service answered with.

`e is AuthRetryableFetchException` still catches both, so the retry and
refresh paths in `GoTrueClient` are unchanged.
Most of the exception test files checked that Dart assigns constructor
arguments to fields and that `extends` produces a subtype. Neither can fail.

Removed:

- Constructor round trips: build an exception, read back the values just
  passed in. Nine of these across the three files.
- Type checks the compiler already guarantees, such as
  `expect(AuthApiException(...), isA<AuthException>())`, where the static type
  of the expression makes the matcher a tautology. The hierarchy test keeps
  its lists typed as `SupabaseException` so its checks stay real narrowings.
- The whole `Error handling scenarios` group in gotrue: six tests restating
  per-class tests with different string literals.
- `maintains equality based on parent class and reasons`, which compared field
  by field and never invoked `==`, so it would have passed with equality
  broken.
- Per-subclass `toString` tests for subclasses that do not override it, which
  only re-asserted the base and mixin formats already covered in
  `supabase_common`.

What is left covers behaviour that can break: the two `toString` formats,
equality including the runtime type guard, the constructors that derive a
message or an error code, `StorageApiException.fromJson`, and which
exceptions are `SupabaseApiException`. The kept `toString` assertions are now
exact matches instead of `contains`, so a format change fails them.

gotrue's exception test drops from 40 tests to 17.
…rename PostgrestException

Two alignment fixes from review.

`FunctionsHttpException` and `FunctionsApiException` described the same thing:
a non-2xx response from the Edge Function. That is the definition of an api
exception, so `FunctionsApiException` takes over the name and the default
message, and `FunctionsHttpException` is gone. `FunctionsRelayException` stays,
since a relay error means the function may never have run.

`PostgrestException` becomes `PostgrestApiException`. Every Postgrest failure
comes from a response, so it was the only api exception not carrying the `Api`
part, and the plain name is now free for a client raised base if one is ever
needed.

Also trims the exception dartdocs to what a caller needs, dropping the notes
that explained the naming and the layering rationale.
@spydon
spydon force-pushed the breaking/supabase-exception-base branch from dd6f916 to 2476ef4 Compare August 12, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants