Skip to content

Commit 8ad8739

Browse files
authored
feat(excetions): add base exceptions (#39)
Co-authored-by: Datata1 <>
1 parent e3861e8 commit 8ad8739

File tree

6 files changed

+296
-43
lines changed

6 files changed

+296
-43
lines changed

src/codesphere/__init__.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,34 +20,55 @@
2020
"""
2121

2222
import logging
23-
from .client import CodesphereSDK
2423

25-
from .exceptions import CodesphereError, AuthenticationError
24+
from .client import CodesphereSDK
25+
from .exceptions import (
26+
APIError,
27+
AuthenticationError,
28+
AuthorizationError,
29+
CodesphereError,
30+
ConflictError,
31+
NetworkError,
32+
NotFoundError,
33+
RateLimitError,
34+
TimeoutError,
35+
ValidationError,
36+
)
37+
from .resources.metadata import Characteristic, Datacenter, Image, WsPlan
2638
from .resources.team import (
27-
Team,
28-
TeamCreate,
29-
TeamBase,
30-
Domain,
3139
CustomDomainConfig,
32-
DomainVerificationStatus,
40+
Domain,
3341
DomainBase,
3442
DomainRouting,
43+
DomainVerificationStatus,
44+
Team,
45+
TeamBase,
46+
TeamCreate,
3547
)
3648
from .resources.workspace import (
3749
Workspace,
3850
WorkspaceCreate,
39-
WorkspaceUpdate,
4051
WorkspaceStatus,
52+
WorkspaceUpdate,
4153
)
4254
from .resources.workspace.envVars import EnvVar
43-
from .resources.metadata import Datacenter, Characteristic, WsPlan, Image
4455

4556
logging.getLogger("codesphere").addHandler(logging.NullHandler())
4657

4758
__all__ = [
4859
"CodesphereSDK",
60+
# Exceptions
4961
"CodesphereError",
5062
"AuthenticationError",
63+
"AuthorizationError",
64+
"NotFoundError",
65+
"ValidationError",
66+
"ConflictError",
67+
"RateLimitError",
68+
"APIError",
69+
"NetworkError",
70+
"TimeoutError",
71+
# Resources
5172
"Team",
5273
"TeamCreate",
5374
"TeamBase",
@@ -64,6 +85,5 @@
6485
"CustomDomainConfig",
6586
"DomainVerificationStatus",
6687
"DomainBase",
67-
"DomainsResource",
6888
"DomainRouting",
6989
]

src/codesphere/exceptions.py

Lines changed: 219 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,230 @@
1+
from typing import Any, Optional
2+
3+
import httpx
4+
5+
16
class CodesphereError(Exception):
2-
"""Base exception class for all errors in the Codesphere SDK."""
7+
"""Base exception class for all errors in the Codesphere SDK.
8+
9+
All SDK exceptions inherit from this, so users can catch this
10+
to handle any SDK-related error.
11+
"""
312

4-
pass
13+
def __init__(self, message: str = "An error occurred in the Codesphere SDK."):
14+
self.message = message
15+
super().__init__(self.message)
516

617

718
class AuthenticationError(CodesphereError):
8-
"""Raised for authentication-related errors, like a missing API token."""
19+
"""Raised for authentication-related errors, like a missing or invalid API token.
920
10-
def __init__(self, message: str = None):
21+
HTTP Status: 401
22+
"""
23+
24+
def __init__(self, message: Optional[str] = None):
1125
if message is None:
1226
message = (
13-
"Authentication token not provided. Please pass it as an argument "
27+
"Authentication token not provided or invalid. Please pass it as an argument "
1428
"or set the 'CS_TOKEN' environment variable."
1529
)
1630
super().__init__(message)
31+
32+
33+
class AuthorizationError(CodesphereError):
34+
"""Raised when the user doesn't have permission to perform an action.
35+
36+
HTTP Status: 403
37+
"""
38+
39+
def __init__(self, message: Optional[str] = None):
40+
if message is None:
41+
message = "You don't have permission to perform this action."
42+
super().__init__(message)
43+
44+
45+
class NotFoundError(CodesphereError):
46+
"""Raised when the requested resource does not exist.
47+
48+
HTTP Status: 404
49+
"""
50+
51+
def __init__(self, message: Optional[str] = None, resource: Optional[str] = None):
52+
self.resource = resource
53+
if message is None:
54+
if resource:
55+
message = f"The requested {resource} was not found."
56+
else:
57+
message = "The requested resource was not found."
58+
super().__init__(message)
59+
60+
61+
class ValidationError(CodesphereError):
62+
"""Raised when the request data is invalid or malformed.
63+
64+
HTTP Status: 400, 422
65+
"""
66+
67+
def __init__(
68+
self,
69+
message: Optional[str] = None,
70+
errors: Optional[list[dict[str, Any]]] = None,
71+
):
72+
self.errors = errors or []
73+
if message is None:
74+
message = "The request data was invalid."
75+
super().__init__(message)
76+
77+
78+
class ConflictError(CodesphereError):
79+
"""Raised when there's a conflict with the current state of a resource.
80+
81+
HTTP Status: 409
82+
"""
83+
84+
def __init__(self, message: Optional[str] = None):
85+
if message is None:
86+
message = "The request conflicts with the current state of the resource."
87+
super().__init__(message)
88+
89+
90+
class RateLimitError(CodesphereError):
91+
"""Raised when rate limits are exceeded.
92+
93+
HTTP Status: 429
94+
"""
95+
96+
def __init__(
97+
self,
98+
message: Optional[str] = None,
99+
retry_after: Optional[int] = None,
100+
):
101+
self.retry_after = retry_after
102+
if message is None:
103+
if retry_after:
104+
message = f"Rate limit exceeded. Retry after {retry_after} seconds."
105+
else:
106+
message = "Rate limit exceeded. Please slow down your requests."
107+
super().__init__(message)
108+
109+
110+
class APIError(CodesphereError):
111+
"""Raised for general API errors that don't fit other categories.
112+
113+
Contains detailed information about the failed request.
114+
"""
115+
116+
def __init__(
117+
self,
118+
message: Optional[str] = None,
119+
status_code: Optional[int] = None,
120+
response_body: Optional[Any] = None,
121+
request_url: Optional[str] = None,
122+
request_method: Optional[str] = None,
123+
):
124+
self.status_code = status_code
125+
self.response_body = response_body
126+
self.request_url = request_url
127+
self.request_method = request_method
128+
129+
if message is None:
130+
message = f"API request failed with status {status_code}."
131+
super().__init__(message)
132+
133+
def __str__(self) -> str:
134+
parts = [self.message]
135+
if self.status_code:
136+
parts.append(f"Status: {self.status_code}")
137+
if self.request_method and self.request_url:
138+
parts.append(f"Request: {self.request_method} {self.request_url}")
139+
return " | ".join(parts)
140+
141+
142+
class NetworkError(CodesphereError):
143+
"""Raised for network-related issues like connection failures or timeouts."""
144+
145+
def __init__(
146+
self, message: Optional[str] = None, original_error: Optional[Exception] = None
147+
):
148+
self.original_error = original_error
149+
if message is None:
150+
message = "A network error occurred while connecting to the API."
151+
super().__init__(message)
152+
153+
154+
class TimeoutError(NetworkError):
155+
"""Raised when a request times out."""
156+
157+
def __init__(self, message: Optional[str] = None):
158+
if message is None:
159+
message = "The request timed out. The server may be slow or unavailable."
160+
super().__init__(message)
161+
162+
163+
def raise_for_status(response: httpx.Response) -> None:
164+
"""Convert HTTP errors to appropriate SDK exceptions.
165+
166+
This function should be called after every API request to translate
167+
HTTP errors into user-friendly SDK exceptions.
168+
169+
Args:
170+
response: The httpx Response object to check.
171+
172+
Raises:
173+
AuthenticationError: For 401 responses.
174+
AuthorizationError: For 403 responses.
175+
NotFoundError: For 404 responses.
176+
ValidationError: For 400/422 responses.
177+
ConflictError: For 409 responses.
178+
RateLimitError: For 429 responses.
179+
APIError: For other 4xx/5xx responses.
180+
"""
181+
if response.is_success:
182+
return
183+
184+
status_code = response.status_code
185+
186+
error_message = None
187+
response_body = None
188+
try:
189+
response_body = response.json()
190+
error_message = (
191+
response_body.get("message")
192+
or response_body.get("error")
193+
or response_body.get("detail")
194+
or response_body.get("errors")
195+
)
196+
if isinstance(error_message, list):
197+
error_message = "; ".join(str(e) for e in error_message)
198+
except Exception:
199+
error_message = response.text or None
200+
201+
request_url = str(response.request.url) if response.request else None
202+
request_method = response.request.method if response.request else None
203+
204+
if status_code == 401:
205+
raise AuthenticationError(error_message)
206+
elif status_code == 403:
207+
raise AuthorizationError(error_message)
208+
elif status_code == 404:
209+
raise NotFoundError(error_message)
210+
elif status_code in (400, 422):
211+
errors = (
212+
response_body.get("errors") if isinstance(response_body, dict) else None
213+
)
214+
raise ValidationError(error_message, errors=errors)
215+
elif status_code == 409:
216+
raise ConflictError(error_message)
217+
elif status_code == 429:
218+
retry_after = response.headers.get("Retry-After")
219+
retry_after_int = (
220+
int(retry_after) if retry_after and retry_after.isdigit() else None
221+
)
222+
raise RateLimitError(error_message, retry_after=retry_after_int)
223+
else:
224+
raise APIError(
225+
message=error_message,
226+
status_code=status_code,
227+
response_body=response_body,
228+
request_url=request_url,
229+
request_method=request_method,
230+
)

src/codesphere/http_client.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
from functools import partial
21
import logging
2+
from functools import partial
3+
from typing import Any, Optional
4+
35
import httpx
46
from pydantic import BaseModel
5-
from typing import Optional, Any
7+
68
from .config import settings
9+
from .exceptions import NetworkError, TimeoutError, raise_for_status
710

811
log = logging.getLogger(__name__)
912

@@ -68,18 +71,21 @@ async def request(
6871
f"Response: {response.status_code} {response.reason_phrase} for {method} {endpoint}"
6972
)
7073

71-
response.raise_for_status()
74+
raise_for_status(response)
7275
return response
7376

74-
except httpx.HTTPStatusError as e:
75-
log.error(
76-
f"HTTP Error {e.response.status_code} for {e.request.method} {e.request.url}"
77-
)
78-
try:
79-
log.error(f"Error Response Body: {e.response.json()}")
80-
except Exception:
81-
log.error(f"Error Response Body (non-json): {e.response.text}")
82-
raise e
83-
except Exception as e:
84-
log.error(f"An unexpected error occurred: {e}")
85-
raise e
77+
except httpx.TimeoutException as e:
78+
log.error(f"Request timeout for {method} {endpoint}: {e}")
79+
raise TimeoutError(f"Request to {endpoint} timed out.") from e
80+
except httpx.ConnectError as e:
81+
log.error(f"Connection error for {method} {endpoint}: {e}")
82+
raise NetworkError(
83+
f"Failed to connect to the API: {e}",
84+
original_error=e,
85+
) from e
86+
except httpx.RequestError as e:
87+
log.error(f"Network error for {method} {endpoint}: {e}")
88+
raise NetworkError(
89+
f"A network error occurred: {e}",
90+
original_error=e,
91+
) from e

src/codesphere/resources/workspace/resources.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
from typing import List
2+
23
from pydantic import Field
34

5+
from ...core import ResourceBase
46
from ...core.base import ResourceList
57
from ...core.operations import AsyncCallable
8+
from ...exceptions import ValidationError
69
from .operations import (
710
_CREATE_OP,
811
_GET_OP,
912
_LIST_BY_TEAM_OP,
1013
)
11-
12-
from ...core import ResourceBase
1314
from .schemas import Workspace, WorkspaceCreate
1415

1516

@@ -19,12 +20,16 @@ class WorkspacesResource(ResourceBase):
1920
)
2021

2122
async def list(self, team_id: int) -> List[Workspace]:
23+
if team_id <= 0:
24+
raise ValidationError("team_id must be a positive integer")
2225
result = await self.list_by_team_op(team_id=team_id)
2326
return result.root
2427

2528
get_op: AsyncCallable[Workspace] = Field(default=_GET_OP, exclude=True)
2629

2730
async def get(self, workspace_id: int) -> Workspace:
31+
if workspace_id <= 0:
32+
raise ValidationError("workspace_id must be a positive integer")
2833
return await self.get_op(workspace_id=workspace_id)
2934

3035
create_op: AsyncCallable[Workspace] = Field(default=_CREATE_OP, exclude=True)

0 commit comments

Comments
 (0)