Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 27 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,30 @@ The client constructors of `supabase_auth`, `postgrest`, `storage3` and `supabas
`request_timeout` is in seconds and limits how long a request may go without progress. Set it with the `request_timeout` argument of the client for every service, on `PostgrestClientOptions`, `AuthClientOptions`, `StorageClientOptions` or `FunctionsClientOptions` for one service, with `request_timeout()` on a database query, or with the `request_timeout` argument of `functions.invoke()`. None, the default, leaves the timeout to the HTTP client.

A timed-out request raises `supabase.RequestTimeoutError`, a subclass of the built-in `TimeoutError`, instead of an httpx or aiohttp exception. This also applies to timeouts configured on the HTTP client.

## One exception hierarchy

Every exception the packages raise inherits from `supabase_utils.errors.SupabaseError`, re-exported as `supabase.SupabaseError`, so `except SupabaseError` catches any failure of the SDK. The hierarchy is described in the [error handling](src/supabase/README.md#error-handling) section of the README.
Comment thread
spydon marked this conversation as resolved.
Resolved

Errors a service answered with inherit from `SupabaseAPIError` and expose the HTTP status as `status_code`, with the `response` next to it. The `status` attributes are renamed and the constructors take the status as a keyword argument.

| Package | Before | After |
| -------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `supabase_auth` | `AuthApiError.status` | `AuthApiError.status_code` |
| `supabase_auth` | `AuthUnknownError.status`, `AuthUnknownError.data` | `AuthUnknownError.status_code`, `AuthUnknownError.response.content` |
| `supabase_auth` | `AuthRetryableError(message, status)` | `AuthRetryableError(message, status_code=...)`, a subclass of `AuthApiError` |
| `supabase_auth` | `AuthWeakPasswordError(message, status, reasons)` | `AuthWeakPasswordError(message, status_code=..., reasons=...)`, a subclass of `AuthApiError` |
| `supabase_auth` | `CustomAuthError(message, status, code)`, `.status` | `CustomAuthError(message, code=...)`, no status: `AuthSessionMissingError` and the other client-side errors come from no response |
| `postgrest` | `APIError(error)` | `APIError(error, status_code=..., response=...)`, with `status_code` and `response` attributes |
| `postgrest` | `str(error)` was the error dictionary | `str(error)` is the error message, `error.json()` is the dictionary |
| `storage3` | `StorageApiError(message=..., code=..., status=...)` | `StorageApiError(message, status_code=..., code=..., response=...)` |
| `storage3` | `StorageApiError.status` | `StorageApiError.status_code` |
| `supabase_functions` | `FunctionsError(message, name, status)`, `.name`, `.status` | `FunctionsError(message)`. Use `type(error).__name__` for the name; the status lives on the response errors |
| `supabase_functions` | `FunctionsHttpError(message, code)`, `.status` | `FunctionsHttpError(message, status_code=..., response=...)`, `.status_code` |
| `supabase_functions` | `FunctionsRelayError(message, code)`, `.status` | `FunctionsRelayError(message, status_code=..., response=...)`, `.status_code` |
| `realtime` | `realtime.client.MaxRetriesExceeded`, an `Exception` | `realtime.MaxRetriesExceeded`, a `RealtimeError` with a `retries` attribute |
| `realtime` | A failed `subscribe()` raised `Exception` | It raises `RealtimeError` |

The auth errors are no longer dataclasses. `str(error)` returns the message for every error, as it does for the built-in exceptions, and `==` compares identity.

A request that fails before a response arrives raises `supabase.SupabaseTransportError` instead of an httpx or aiohttp exception, with that exception chained as `__cause__`. `RequestTimeoutError` is a subclass of it, and still a subclass of the built-in `TimeoutError`. Code that caught `httpx.ConnectError` or `aiohttp.ClientConnectorError` around a call of the SDK should catch `SupabaseTransportError` instead. The outcome of such a request is unknown, since it may have reached the service.
8 changes: 7 additions & 1 deletion sdk-compliance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@ features:
storage.configuration.auto_retry: not_implemented
storage.errors.error_codes:
status: implemented
note: "The error code is exposed as the StorageApiError.code dataclass field, which is not registrable on its own since only properties are extracted as symbols."
note: "The error code is exposed as the StorageApiError.code attribute, which is not registrable on its own since only properties are extracted as symbols."
symbols:
- StorageApiError
supporting_symbols:
Expand Down Expand Up @@ -1441,6 +1441,12 @@ supporting_symbols:
- FunctionsRelayError
- AuthorizationError
- NotConnectedError
- MaxRetriesExceeded
- RealtimeError
- SupabaseError
- SupabaseAPIError
- SupabaseTransportError
- RequestTimeoutError
- ChannelEvents
- ChannelStates
- RealtimeSubscribeStates
Expand Down
123 changes: 74 additions & 49 deletions src/auth/src/supabase_auth/errors.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal

from pydantic import BaseModel, TypeAdapter, ValidationError, field_validator
from supabase_utils.errors import SupabaseError
from supabase_utils.http.errors import SupabaseAPIError

if TYPE_CHECKING:
from supabase_utils.http.request import Response

ErrorCode = Literal[
"unexpected_failure",
Expand Down Expand Up @@ -124,53 +128,69 @@ def get_error_message(self) -> str:
)


@dataclass
class AuthError(Exception):
message: str
code: ErrorCode | None
class AuthError(SupabaseError):
"""Raised when an auth operation fails.

A plain `AuthError` is a failure the client raised on its own, such as a
missing session. A failure the auth service reported is an `AuthApiError`.
`code` is one of `ErrorCode` when the service named the failure, see
https://supabase.com/docs/guides/auth/debugging/error-codes.
"""

@dataclass
class AuthApiError(AuthError):
status: int

class AuthApiError(AuthError, SupabaseAPIError):
"""Raised when the auth service answered a request with an error."""

@dataclass
class AuthUnknownError(AuthError):
data: bytes
status: int
def __init__(
self,
message: str,
*,
status_code: int,
code: ErrorCode | None = None,
response: Response | None = None,
) -> None:
super().__init__(message, status_code=status_code, code=code, response=response)


class AuthUnknownError(AuthError, SupabaseAPIError):
"""Raised when the auth service answered with an error that could not be
parsed. The body is `response.content`."""

def __init__(
self,
message: str,
*,
status_code: int,
code: ErrorCode | None = None,
response: Response | None = None,
) -> None:
super().__init__(message, status_code=status_code, code=code, response=response)


@dataclass
class CustomAuthError(AuthError):
status: int
"""Base class of the errors the auth client raises on its own, without a
response from the auth service."""

def __init__(self, message: str, *, code: ErrorCode | None = None) -> None:
super().__init__(message, code=code)


class UserDoesNotExistError(CustomAuthError):
def __init__(self) -> None:
super().__init__(
message="User does not exist for the provided access token",
status=404,
"User does not exist for the provided access token",
code="user_not_found",
)


class AuthSessionMissingError(CustomAuthError):
def __init__(self) -> None:
super().__init__(
message="Auth session missing!",
status=400,
code=None,
)
super().__init__("Auth session missing!")


class AuthInvalidCredentialsError(CustomAuthError):
def __init__(self, message: str) -> None:
super().__init__(
message=message,
status=400,
code=None,
)
super().__init__(message)


class AuthImplicitGrantRedirectError(CustomAuthError):
Expand All @@ -180,38 +200,43 @@ def __init__(
error: str | None = None,
code: str | None = None,
) -> None:
super().__init__(
message=message,
status=500,
code=None,
)
super().__init__(message)
self.detail_error = error
self.detail_code = code


class AuthRetryableError(CustomAuthError):
def __init__(self, message: str, status: int) -> None:
super().__init__(
message,
status=status,
code=None,
)
class AuthRetryableError(AuthApiError):
"""Raised when the auth service answered with a status that is worth
retrying, such as a gateway error."""

def __init__(
self,
message: str,
*,
status_code: int,
response: Response | None = None,
) -> None:
super().__init__(message, status_code=status_code, response=response)


class AuthWeakPasswordError(AuthApiError):
"""Raised when the auth service rejected a password. `reasons` lists what
it lacked."""

class AuthWeakPasswordError(CustomAuthError):
def __init__(self, message: str, status: int, reasons: list[str]) -> None:
def __init__(
self,
message: str,
*,
status_code: int,
reasons: list[str],
response: Response | None = None,
) -> None:
super().__init__(
message=message,
status=status,
code="weak_password",
message, status_code=status_code, code="weak_password", response=response
)
self.reasons = reasons


class AuthInvalidJwtError(CustomAuthError):
def __init__(self, message: str) -> None:
super().__init__(
message=message,
status=400,
code="invalid_jwt",
)
super().__init__(message, code="invalid_jwt")
37 changes: 26 additions & 11 deletions src/auth/src/supabase_auth/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,27 @@ def _handle_error_response(response: Response) -> AuthError:
raw_error = RawApiError.model_validate_json(response.content)
except ValidationError:
return AuthUnknownError(
message="Unexpected error: Unable to parse API error",
"Unexpected error: Unable to parse API error",
status_code=response.status,
code="unexpected_failure",
status=response.status,
data=response.content,
response=response,
)
if not response.is_error or response.status in {
502,
503,
504,
520,
521,
522,
523,
524,
530,
}:
return AuthRetryableError(
raw_error.get_error_message(),
status_code=response.status,
response=response,
)
if not response.is_error:
return AuthRetryableError(raw_error.get_error_message(), response.status)
if response.status in {502, 503, 504, 520, 521, 522, 523, 524, 530}:
return AuthRetryableError(raw_error.get_error_message(), response.status)
error_code = None
response_api_version = parse_response_api_version(response)

Expand All @@ -142,21 +154,24 @@ def _handle_error_response(response: Response) -> AuthError:
and raw_error.weak_password.reasons
):
return AuthWeakPasswordError(
message=raw_error.get_error_message(),
status=response.status,
raw_error.get_error_message(),
status_code=response.status,
reasons=raw_error.weak_password.reasons,
response=response,
)
if error_code == "weak_password":
return AuthWeakPasswordError(
raw_error.get_error_message(),
status=response.status,
status_code=response.status,
reasons=raw_error.weak_password.reasons if raw_error.weak_password else [],
response=response,
)

return AuthApiError(
raw_error.get_error_message(),
status=response.status or 500,
status_code=response.status or 500,
code=error_code,
response=response,
)


Expand Down
Loading
Loading