Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.

Commit 05dfb8a

Browse files
authored
feat: add error codes (#556)
1 parent d2e0b63 commit 05dfb8a

File tree

9 files changed

+566
-237
lines changed

9 files changed

+566
-237
lines changed

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ clean_infra:
2222
docker compose down --remove-orphans &&\
2323
docker system prune -a --volumes -f
2424

25+
stop_infra:
26+
cd infra &&\
27+
docker compose down --remove-orphans
28+
2529
sync_infra:
2630
python scripts/gh-download.py --repo=supabase/gotrue-js --branch=master --folder=infra
2731

poetry.lock

Lines changed: 272 additions & 226 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ unasync-cli = "^0.0.9"
3333

3434
[tool.poetry.group.dev.dependencies]
3535
pygithub = ">=1.57,<3.0"
36+
respx = ">=0.20.2,<0.22.0"
3637

3738
[build-system]
3839
requires = ["poetry-core>=1.0.0"]

supabase_auth/_async/gotrue_base_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pydantic import BaseModel
77
from typing_extensions import Literal, Self
88

9+
from ..constants import API_VERSION_HEADER_NAME, API_VERSIONS
910
from ..helpers import handle_exception, model_dump
1011
from ..http_clients import AsyncClient
1112

@@ -97,6 +98,8 @@ async def _request(
9798
) -> Union[T, None]:
9899
url = f"{self._url}/{path}"
99100
headers = {**self._headers, **(headers or {})}
101+
if API_VERSION_HEADER_NAME not in headers:
102+
headers[API_VERSION_HEADER_NAME] = API_VERSIONS["2024-01-01"].get("name")
100103
if "Content-Type" not in headers:
101104
headers["Content-Type"] = "application/json;charset=UTF-8"
102105
if jwt:

supabase_auth/_sync/gotrue_base_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pydantic import BaseModel
77
from typing_extensions import Literal, Self
88

9+
from ..constants import API_VERSION_HEADER_NAME, API_VERSIONS
910
from ..helpers import handle_exception, model_dump
1011
from ..http_clients import SyncClient
1112

@@ -97,6 +98,8 @@ def _request(
9798
) -> Union[T, None]:
9899
url = f"{self._url}/{path}"
99100
headers = {**self._headers, **(headers or {})}
101+
if API_VERSION_HEADER_NAME not in headers:
102+
headers[API_VERSION_HEADER_NAME] = API_VERSIONS["2024-01-01"].get("name")
100103
if "Content-Type" not in headers:
101104
headers["Content-Type"] = "application/json;charset=UTF-8"
102105
if jwt:

supabase_auth/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from datetime import datetime
34
from typing import Dict
45

56
from .version import __version__
@@ -12,3 +13,11 @@
1213
MAX_RETRIES = 10
1314
RETRY_INTERVAL = 2 # deciseconds
1415
STORAGE_KEY = "supabase.auth.token"
16+
17+
API_VERSION_HEADER_NAME = "X-Supabase-Api-Version"
18+
API_VERSIONS = {
19+
"2024-01-01": {
20+
"timestamp": datetime.timestamp(datetime.strptime("2024-01-01", "%Y-%m-%d")),
21+
"name": "2024-01-01",
22+
},
23+
}

supabase_auth/errors.py

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,119 @@
11
from __future__ import annotations
22

3-
from typing import Union
3+
from typing import List, Literal, Union
44

55
from typing_extensions import TypedDict
66

7+
ErrorCode = Literal[
8+
"unexpected_failure",
9+
"validation_failed",
10+
"bad_json",
11+
"email_exists",
12+
"phone_exists",
13+
"bad_jwt",
14+
"not_admin",
15+
"no_authorization",
16+
"user_not_found",
17+
"session_not_found",
18+
"flow_state_not_found",
19+
"flow_state_expired",
20+
"signup_disabled",
21+
"user_banned",
22+
"provider_email_needs_verification",
23+
"invite_not_found",
24+
"bad_oauth_state",
25+
"bad_oauth_callback",
26+
"oauth_provider_not_supported",
27+
"unexpected_audience",
28+
"single_identity_not_deletable",
29+
"email_conflict_identity_not_deletable",
30+
"identity_already_exists",
31+
"email_provider_disabled",
32+
"phone_provider_disabled",
33+
"too_many_enrolled_mfa_factors",
34+
"mfa_factor_name_conflict",
35+
"mfa_factor_not_found",
36+
"mfa_ip_address_mismatch",
37+
"mfa_challenge_expired",
38+
"mfa_verification_failed",
39+
"mfa_verification_rejected",
40+
"insufficient_aal",
41+
"captcha_failed",
42+
"saml_provider_disabled",
43+
"manual_linking_disabled",
44+
"sms_send_failed",
45+
"email_not_confirmed",
46+
"phone_not_confirmed",
47+
"reauth_nonce_missing",
48+
"saml_relay_state_not_found",
49+
"saml_relay_state_expired",
50+
"saml_idp_not_found",
51+
"saml_assertion_no_user_id",
52+
"saml_assertion_no_email",
53+
"user_already_exists",
54+
"sso_provider_not_found",
55+
"saml_metadata_fetch_failed",
56+
"saml_idp_already_exists",
57+
"sso_domain_already_exists",
58+
"saml_entity_id_mismatch",
59+
"conflict",
60+
"provider_disabled",
61+
"user_sso_managed",
62+
"reauthentication_needed",
63+
"same_password",
64+
"reauthentication_not_valid",
65+
"otp_expired",
66+
"otp_disabled",
67+
"identity_not_found",
68+
"weak_password",
69+
"over_request_rate_limit",
70+
"over_email_send_rate_limit",
71+
"over_sms_send_rate_limit",
72+
"bad_code_verifier",
73+
]
74+
775

876
class AuthError(Exception):
9-
def __init__(self, message: str) -> None:
77+
def __init__(self, message: str, code: ErrorCode) -> None:
1078
Exception.__init__(self, message)
1179
self.message = message
1280
self.name = "AuthError"
81+
self.code = code
1382

1483

1584
class AuthApiErrorDict(TypedDict):
1685
name: str
1786
message: str
1887
status: int
88+
code: ErrorCode
1989

2090

2191
class AuthApiError(AuthError):
22-
def __init__(self, message: str, status: int) -> None:
23-
AuthError.__init__(self, message)
92+
def __init__(self, message: str, status: int, code: ErrorCode) -> None:
93+
AuthError.__init__(self, message, code)
2494
self.name = "AuthApiError"
2595
self.status = status
96+
self.code = code
2697

2798
def to_dict(self) -> AuthApiErrorDict:
2899
return {
29100
"name": self.name,
30101
"message": self.message,
31102
"status": self.status,
103+
"code": self.code,
32104
}
33105

34106

35107
class AuthUnknownError(AuthError):
36108
def __init__(self, message: str, original_error: Exception) -> None:
37-
AuthError.__init__(self, message)
109+
AuthError.__init__(self, message, None)
38110
self.name = "AuthUnknownError"
39111
self.original_error = original_error
40112

41113

42114
class CustomAuthError(AuthError):
43-
def __init__(self, message: str, name: str, status: int) -> None:
44-
AuthError.__init__(self, message)
115+
def __init__(self, message: str, name: str, status: int, code: ErrorCode) -> None:
116+
AuthError.__init__(self, message, code)
45117
self.name = name
46118
self.status = status
47119

@@ -60,6 +132,7 @@ def __init__(self) -> None:
60132
"Auth session missing!",
61133
"AuthSessionMissingError",
62134
400,
135+
None,
63136
)
64137

65138

@@ -70,6 +143,7 @@ def __init__(self, message: str) -> None:
70143
message,
71144
"AuthInvalidCredentialsError",
72145
400,
146+
None,
73147
)
74148

75149

@@ -93,6 +167,7 @@ def __init__(
93167
message,
94168
"AuthImplicitGrantRedirectError",
95169
500,
170+
None,
96171
)
97172
self.details = details
98173

@@ -112,4 +187,25 @@ def __init__(self, message: str, status: int) -> None:
112187
message,
113188
"AuthRetryableError",
114189
status,
190+
None,
115191
)
192+
193+
194+
class AuthWeakPasswordError(CustomAuthError):
195+
def __init__(self, message: str, status: int, reasons: List[str]) -> None:
196+
CustomAuthError.__init__(
197+
self,
198+
message,
199+
"AuthWeakPasswordError",
200+
status,
201+
"weak_password",
202+
)
203+
self.reasons = reasons
204+
205+
def to_dict(self) -> AuthApiErrorDict:
206+
return {
207+
"name": self.name,
208+
"message": self.message,
209+
"status": self.status,
210+
"reasons": self.reasons,
211+
}

supabase_auth/helpers.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@
22

33
import base64
44
import hashlib
5+
import re
56
import secrets
67
import string
78
from base64 import urlsafe_b64decode
9+
from datetime import datetime
810
from json import loads
911
from typing import Any, Dict, Type, TypeVar, Union, cast
1012

11-
from httpx import HTTPStatusError
13+
from httpx import HTTPStatusError, Response
1214
from pydantic import BaseModel
1315

14-
from .errors import AuthApiError, AuthError, AuthRetryableError, AuthUnknownError
16+
from .constants import API_VERSION_HEADER_NAME, API_VERSIONS
17+
from .errors import (
18+
AuthApiError,
19+
AuthError,
20+
AuthRetryableError,
21+
AuthUnknownError,
22+
AuthWeakPasswordError,
23+
)
1524
from .types import (
1625
AuthOtpResponse,
1726
AuthResponse,
@@ -114,6 +123,10 @@ def get_error_message(error: Any) -> str:
114123
return next((error[prop] for prop in props if filter(prop)), str(error))
115124

116125

126+
def get_error_code(error: Any) -> str:
127+
return error.get("error_code", None) if isinstance(error, dict) else None
128+
129+
117130
def looks_like_http_status_error(exception: Exception) -> bool:
118131
return isinstance(exception, HTTPStatusError)
119132

@@ -128,8 +141,51 @@ def handle_exception(exception: Exception) -> AuthError:
128141
return AuthRetryableError(
129142
get_error_message(error), error.response.status_code
130143
)
131-
json = error.response.json()
132-
return AuthApiError(get_error_message(json), error.response.status_code or 500)
144+
data = error.response.json()
145+
146+
error_code = None
147+
response_api_version = parse_response_api_version(error.response)
148+
149+
if (
150+
response_api_version
151+
and datetime.timestamp(response_api_version)
152+
>= API_VERSIONS.get("2024-01-01").get("timestamp")
153+
and isinstance(data, dict)
154+
and data
155+
and isinstance(data.get("code"), str)
156+
):
157+
error_code = data.get("code")
158+
elif (
159+
isinstance(data, dict) and data and isinstance(data.get("error_code"), str)
160+
):
161+
error_code = data.get("error_code")
162+
163+
if error_code is None:
164+
if (
165+
isinstance(data, dict)
166+
and data
167+
and isinstance(data.get("weak_password"), dict)
168+
and data.get("weak_password")
169+
and isinstance(data.get("weak_password"), list)
170+
and len(data.get("weak_password"))
171+
):
172+
return AuthWeakPasswordError(
173+
get_error_message(data),
174+
error.response.status_code,
175+
data.get("weak_password").get("reasons"),
176+
)
177+
elif error_code == "weak_password":
178+
return AuthWeakPasswordError(
179+
get_error_message(data),
180+
error.response.status_code,
181+
data.get("weak_password", {}).get("reasons", {}),
182+
)
183+
184+
return AuthApiError(
185+
get_error_message(data),
186+
error.response.status_code or 500,
187+
error_code,
188+
)
133189
except Exception as e:
134190
return AuthUnknownError(get_error_message(error), e)
135191

@@ -163,3 +219,22 @@ def generate_pkce_challenge(code_verifier):
163219
sha256_hash = hashlib.sha256(verifier_bytes).digest()
164220

165221
return base64.urlsafe_b64encode(sha256_hash).rstrip(b"=").decode("utf-8")
222+
223+
224+
API_VERSION_REGEX = r"^2[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])$"
225+
226+
227+
def parse_response_api_version(response: Response):
228+
api_version = response.headers.get(API_VERSION_HEADER_NAME)
229+
230+
if not api_version:
231+
return None
232+
233+
if re.search(API_VERSION_REGEX, api_version) is None:
234+
return None
235+
236+
try:
237+
dt = datetime.strptime(api_version, "%Y-%m-%d")
238+
return dt
239+
except Exception as e:
240+
return None

0 commit comments

Comments
 (0)