byoi 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- byoi/__init__.py +233 -0
- byoi/__main__.py +228 -0
- byoi/cache.py +346 -0
- byoi/config.py +349 -0
- byoi/dependencies.py +144 -0
- byoi/errors.py +360 -0
- byoi/models.py +451 -0
- byoi/pkce.py +144 -0
- byoi/providers.py +434 -0
- byoi/py.typed +0 -0
- byoi/repositories.py +252 -0
- byoi/service.py +723 -0
- byoi/telemetry.py +352 -0
- byoi/tokens.py +340 -0
- byoi/types.py +130 -0
- byoi-0.1.0a1.dist-info/METADATA +504 -0
- byoi-0.1.0a1.dist-info/RECORD +20 -0
- byoi-0.1.0a1.dist-info/WHEEL +4 -0
- byoi-0.1.0a1.dist-info/entry_points.txt +3 -0
- byoi-0.1.0a1.dist-info/licenses/LICENSE +21 -0
byoi/dependencies.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""FastAPI dependencies for the BYOI library.
|
|
2
|
+
|
|
3
|
+
Provides FastAPI dependency injection utilities for accessing BYOI services.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Annotated, Callable
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, Request
|
|
9
|
+
|
|
10
|
+
from byoi.providers import ProviderManager
|
|
11
|
+
from byoi.service import AuthService
|
|
12
|
+
|
|
13
|
+
__all__ = (
|
|
14
|
+
"get_provider_manager",
|
|
15
|
+
"get_auth_service",
|
|
16
|
+
"ProviderManagerDep",
|
|
17
|
+
"AuthServiceDep",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_provider_manager(request: Request) -> ProviderManager:
|
|
22
|
+
"""FastAPI dependency to get the ProviderManager.
|
|
23
|
+
|
|
24
|
+
The ProviderManager must be stored in the application state
|
|
25
|
+
using the key "byoi_provider_manager".
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
request: The FastAPI request.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The ProviderManager instance.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
RuntimeError: If BYOI is not configured in the application.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
```python
|
|
38
|
+
@app.get("/providers")
|
|
39
|
+
async def list_providers(
|
|
40
|
+
provider_manager: ProviderManagerDep,
|
|
41
|
+
):
|
|
42
|
+
return provider_manager.list_providers()
|
|
43
|
+
```
|
|
44
|
+
"""
|
|
45
|
+
provider_manager = getattr(request.app.state, "byoi_provider_manager", None)
|
|
46
|
+
if provider_manager is None:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
"BYOI is not configured. Call BYOI.setup() during application startup."
|
|
49
|
+
)
|
|
50
|
+
return provider_manager
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_auth_service(request: Request) -> AuthService:
|
|
54
|
+
"""FastAPI dependency to get the AuthService.
|
|
55
|
+
|
|
56
|
+
The AuthService must be stored in the application state
|
|
57
|
+
using the key "byoi_auth_service".
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
request: The FastAPI request.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The AuthService instance.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
RuntimeError: If BYOI is not configured in the application.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
```python
|
|
70
|
+
@app.post("/auth/login")
|
|
71
|
+
async def login(
|
|
72
|
+
request: TokenExchangeRequest,
|
|
73
|
+
auth_service: AuthServiceDep,
|
|
74
|
+
):
|
|
75
|
+
return await auth_service.authenticate(request)
|
|
76
|
+
```
|
|
77
|
+
"""
|
|
78
|
+
auth_service = getattr(request.app.state, "byoi_auth_service", None)
|
|
79
|
+
if auth_service is None:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"BYOI is not configured. Call BYOI.setup() during application startup."
|
|
82
|
+
)
|
|
83
|
+
return auth_service
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Type aliases for dependency injection
|
|
87
|
+
ProviderManagerDep = Annotated[ProviderManager, Depends(get_provider_manager)]
|
|
88
|
+
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_provider_manager_dependency(
|
|
92
|
+
provider_manager: ProviderManager,
|
|
93
|
+
) -> Callable[[], ProviderManager]:
|
|
94
|
+
"""Create a custom dependency that returns a specific ProviderManager.
|
|
95
|
+
|
|
96
|
+
Useful for testing or when not using the BYOI class for setup.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
provider_manager: The ProviderManager to return.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A dependency function.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
```python
|
|
106
|
+
provider_manager = ProviderManager(cache=my_cache)
|
|
107
|
+
app.dependency_overrides[get_provider_manager] = (
|
|
108
|
+
create_provider_manager_dependency(provider_manager)
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def dependency() -> ProviderManager:
|
|
114
|
+
return provider_manager
|
|
115
|
+
|
|
116
|
+
return dependency
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def create_auth_service_dependency(
|
|
120
|
+
auth_service: AuthService,
|
|
121
|
+
) -> Callable[[], AuthService]:
|
|
122
|
+
"""Create a custom dependency that returns a specific AuthService.
|
|
123
|
+
|
|
124
|
+
Useful for testing or when not using the BYOI class for setup.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
auth_service: The AuthService to return.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
A dependency function.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
```python
|
|
134
|
+
auth_service = AuthService(...)
|
|
135
|
+
app.dependency_overrides[get_auth_service] = (
|
|
136
|
+
create_auth_service_dependency(auth_service)
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def dependency() -> AuthService:
|
|
142
|
+
return auth_service
|
|
143
|
+
|
|
144
|
+
return dependency
|
byoi/errors.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
__all__ = (
|
|
2
|
+
"BYOIError",
|
|
3
|
+
"ConfigurationError",
|
|
4
|
+
"ProviderError",
|
|
5
|
+
"ProviderNotFoundError",
|
|
6
|
+
"ProviderDiscoveryError",
|
|
7
|
+
"AuthenticationError",
|
|
8
|
+
"InvalidStateError",
|
|
9
|
+
"StateExpiredError",
|
|
10
|
+
"InvalidCodeError",
|
|
11
|
+
"TokenExchangeError",
|
|
12
|
+
"TokenRefreshError",
|
|
13
|
+
"TokenValidationError",
|
|
14
|
+
"InvalidNonceError",
|
|
15
|
+
"InvalidIssuerError",
|
|
16
|
+
"InvalidAudienceError",
|
|
17
|
+
"TokenExpiredError",
|
|
18
|
+
"JWKSFetchError",
|
|
19
|
+
"IdentityError",
|
|
20
|
+
"IdentityAlreadyLinkedError",
|
|
21
|
+
"IdentityNotFoundError",
|
|
22
|
+
"CannotUnlinkLastIdentityError",
|
|
23
|
+
"UserNotFoundError",
|
|
24
|
+
"CacheError",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BYOIError(Exception):
|
|
31
|
+
"""Base class for all BYOI exceptions.
|
|
32
|
+
|
|
33
|
+
All BYOI errors have a message and a unique error code that can be used
|
|
34
|
+
for programmatic error handling.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
```python
|
|
38
|
+
from fastapi import HTTPException
|
|
39
|
+
from byoi import BYOIError, InvalidStateError
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
result = await auth_service.exchange_code(request)
|
|
43
|
+
except InvalidStateError as e:
|
|
44
|
+
raise HTTPException(status_code=400, detail=e.to_dict())
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, code: str = "byoi_error") -> None:
|
|
49
|
+
self.message = message
|
|
50
|
+
self.code = code
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
"""Convert the error to a dictionary suitable for API responses.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A dictionary with 'error', 'code', and 'message' keys.
|
|
58
|
+
"""
|
|
59
|
+
return {
|
|
60
|
+
"error": self.__class__.__name__,
|
|
61
|
+
"code": self.code,
|
|
62
|
+
"message": self.message,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"{self.__class__.__name__}(message={self.message!r}, code={self.code!r})"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# =============================================================================
|
|
70
|
+
# Configuration Errors
|
|
71
|
+
# =============================================================================
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConfigurationError(BYOIError):
|
|
75
|
+
"""Error in BYOI configuration."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, message: str) -> None:
|
|
78
|
+
super().__init__(message, code="configuration_error")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# =============================================================================
|
|
82
|
+
# Provider Errors
|
|
83
|
+
# =============================================================================
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ProviderError(BYOIError):
|
|
87
|
+
"""Base class for provider-related errors."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str, code: str = "provider_error") -> None:
|
|
90
|
+
super().__init__(message, code=code)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ProviderNotFoundError(ProviderError):
|
|
94
|
+
"""The requested provider is not configured."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, provider_name: str) -> None:
|
|
97
|
+
self.provider_name = provider_name
|
|
98
|
+
super().__init__(
|
|
99
|
+
f"Provider '{provider_name}' is not configured",
|
|
100
|
+
code="provider_not_found",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ProviderDiscoveryError(ProviderError):
|
|
105
|
+
"""Failed to discover provider endpoints."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, provider_name: str, reason: str) -> None:
|
|
108
|
+
self.provider_name = provider_name
|
|
109
|
+
self.reason = reason
|
|
110
|
+
super().__init__(
|
|
111
|
+
f"Failed to discover endpoints for provider '{provider_name}': {reason}",
|
|
112
|
+
code="provider_discovery_error",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# =============================================================================
|
|
117
|
+
# Authentication Errors
|
|
118
|
+
# =============================================================================
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AuthenticationError(BYOIError):
|
|
122
|
+
"""Base class for authentication errors."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, message: str, code: str = "authentication_error") -> None:
|
|
125
|
+
super().__init__(message, code=code)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class InvalidStateError(AuthenticationError):
|
|
129
|
+
"""The OAuth state parameter is invalid or not found."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, state: str | None = None) -> None:
|
|
132
|
+
self.state = state
|
|
133
|
+
super().__init__(
|
|
134
|
+
"Invalid or unknown OAuth state parameter",
|
|
135
|
+
code="invalid_state",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str:
|
|
139
|
+
return f"{self.__class__.__name__}(state={self.state!r})"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class StateExpiredError(AuthenticationError):
|
|
143
|
+
"""The OAuth state has expired."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, state: str) -> None:
|
|
146
|
+
self.state = state
|
|
147
|
+
super().__init__(
|
|
148
|
+
"OAuth state has expired",
|
|
149
|
+
code="state_expired",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def __repr__(self) -> str:
|
|
153
|
+
return f"{self.__class__.__name__}(state={self.state!r})"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class InvalidCodeError(AuthenticationError):
|
|
157
|
+
"""The authorization code is invalid."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, reason: str | None = None) -> None:
|
|
160
|
+
self.reason = reason
|
|
161
|
+
message = "Invalid authorization code"
|
|
162
|
+
if reason:
|
|
163
|
+
message = f"{message}: {reason}"
|
|
164
|
+
super().__init__(message, code="invalid_code")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TokenExchangeError(AuthenticationError):
|
|
168
|
+
"""Failed to exchange authorization code for tokens."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, reason: str) -> None:
|
|
171
|
+
self.reason = reason
|
|
172
|
+
super().__init__(
|
|
173
|
+
f"Token exchange failed: {reason}",
|
|
174
|
+
code="token_exchange_error",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str:
|
|
178
|
+
return f"{self.__class__.__name__}(reason={self.reason!r})"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TokenRefreshError(AuthenticationError):
|
|
182
|
+
"""Failed to refresh access token."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, reason: str) -> None:
|
|
185
|
+
self.reason = reason
|
|
186
|
+
super().__init__(
|
|
187
|
+
f"Token refresh failed: {reason}",
|
|
188
|
+
code="token_refresh_error",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def __repr__(self) -> str:
|
|
192
|
+
return f"{self.__class__.__name__}(reason={self.reason!r})"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# =============================================================================
|
|
196
|
+
# Token Validation Errors
|
|
197
|
+
# =============================================================================
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TokenValidationError(AuthenticationError):
|
|
201
|
+
"""Base class for token validation errors."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, message: str, code: str = "token_validation_error") -> None:
|
|
204
|
+
super().__init__(message, code=code)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class InvalidNonceError(TokenValidationError):
|
|
208
|
+
"""The nonce in the ID token doesn't match the expected value."""
|
|
209
|
+
|
|
210
|
+
def __init__(self) -> None:
|
|
211
|
+
super().__init__(
|
|
212
|
+
"ID token nonce does not match expected value",
|
|
213
|
+
code="invalid_nonce",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class InvalidIssuerError(TokenValidationError):
|
|
218
|
+
"""The issuer in the ID token doesn't match the expected value."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, expected: str, actual: str) -> None:
|
|
221
|
+
self.expected = expected
|
|
222
|
+
self.actual = actual
|
|
223
|
+
super().__init__(
|
|
224
|
+
f"ID token issuer mismatch: expected '{expected}', got '{actual}'",
|
|
225
|
+
code="invalid_issuer",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str:
|
|
229
|
+
return f"{self.__class__.__name__}(expected={self.expected!r}, actual={self.actual!r})"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class InvalidAudienceError(TokenValidationError):
|
|
233
|
+
"""The audience in the ID token doesn't match the expected value."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, expected: str, actual: str | list[str]) -> None:
|
|
236
|
+
self.expected = expected
|
|
237
|
+
self.actual = actual
|
|
238
|
+
super().__init__(
|
|
239
|
+
f"ID token audience mismatch: expected '{expected}', got '{actual}'",
|
|
240
|
+
code="invalid_audience",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def __repr__(self) -> str:
|
|
244
|
+
return f"{self.__class__.__name__}(expected={self.expected!r}, actual={self.actual!r})"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TokenExpiredError(TokenValidationError):
|
|
248
|
+
"""The token has expired."""
|
|
249
|
+
|
|
250
|
+
def __init__(self) -> None:
|
|
251
|
+
super().__init__(
|
|
252
|
+
"Token has expired",
|
|
253
|
+
code="token_expired",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class JWKSFetchError(TokenValidationError):
|
|
258
|
+
"""Failed to fetch JWKS from the provider."""
|
|
259
|
+
|
|
260
|
+
def __init__(self, provider_name: str, reason: str) -> None:
|
|
261
|
+
self.provider_name = provider_name
|
|
262
|
+
self.reason = reason
|
|
263
|
+
super().__init__(
|
|
264
|
+
f"Failed to fetch JWKS for provider '{provider_name}': {reason}",
|
|
265
|
+
code="jwks_fetch_error",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def __repr__(self) -> str:
|
|
269
|
+
return f"{self.__class__.__name__}(provider_name={self.provider_name!r}, reason={self.reason!r})"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# =============================================================================
|
|
273
|
+
# Identity Errors
|
|
274
|
+
# =============================================================================
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class IdentityError(BYOIError):
|
|
278
|
+
"""Base class for identity-related errors."""
|
|
279
|
+
|
|
280
|
+
def __init__(self, message: str, code: str = "identity_error") -> None:
|
|
281
|
+
super().__init__(message, code=code)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class IdentityAlreadyLinkedError(IdentityError):
|
|
285
|
+
"""The identity is already linked to a user."""
|
|
286
|
+
|
|
287
|
+
def __init__(
|
|
288
|
+
self,
|
|
289
|
+
provider_name: str,
|
|
290
|
+
provider_subject: str,
|
|
291
|
+
existing_user_id: str | None = None,
|
|
292
|
+
) -> None:
|
|
293
|
+
self.provider_name = provider_name
|
|
294
|
+
self.provider_subject = provider_subject
|
|
295
|
+
self.existing_user_id = existing_user_id
|
|
296
|
+
super().__init__(
|
|
297
|
+
f"Identity from provider '{provider_name}' is already linked to a user",
|
|
298
|
+
code="identity_already_linked",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def __repr__(self) -> str:
|
|
302
|
+
return (
|
|
303
|
+
f"{self.__class__.__name__}(provider_name={self.provider_name!r}, "
|
|
304
|
+
f"provider_subject={self.provider_subject!r}, existing_user_id={self.existing_user_id!r})"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class IdentityNotFoundError(IdentityError):
|
|
309
|
+
"""The identity was not found."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, identity_id: str) -> None:
|
|
312
|
+
self.identity_id = identity_id
|
|
313
|
+
super().__init__(
|
|
314
|
+
f"Identity '{identity_id}' not found",
|
|
315
|
+
code="identity_not_found",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str:
|
|
319
|
+
return f"{self.__class__.__name__}(identity_id={self.identity_id!r})"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class CannotUnlinkLastIdentityError(IdentityError):
|
|
323
|
+
"""Cannot unlink the user's only identity."""
|
|
324
|
+
|
|
325
|
+
def __init__(self, user_id: str) -> None:
|
|
326
|
+
self.user_id = user_id
|
|
327
|
+
super().__init__(
|
|
328
|
+
f"Cannot unlink the only identity for user '{user_id}'",
|
|
329
|
+
code="cannot_unlink_last_identity",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def __repr__(self) -> str:
|
|
333
|
+
return f"{self.__class__.__name__}(user_id={self.user_id!r})"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class UserNotFoundError(IdentityError):
|
|
337
|
+
"""The user was not found."""
|
|
338
|
+
|
|
339
|
+
def __init__(self, user_id: str) -> None:
|
|
340
|
+
self.user_id = user_id
|
|
341
|
+
super().__init__(
|
|
342
|
+
f"User '{user_id}' not found",
|
|
343
|
+
code="user_not_found",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def __repr__(self) -> str:
|
|
347
|
+
return f"{self.__class__.__name__}(user_id={self.user_id!r})"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# =============================================================================
|
|
351
|
+
# Cache Errors
|
|
352
|
+
# =============================================================================
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class CacheError(BYOIError):
|
|
356
|
+
"""Error in cache operations."""
|
|
357
|
+
|
|
358
|
+
def __init__(self, message: str) -> None:
|
|
359
|
+
super().__init__(message, code="cache_error")
|
|
360
|
+
|