Skip to content

Commit 576ebd2

Browse files
authored
Big exception refactor and simplification (#11)
* Big exception refactor and simplification * fix test coverage
1 parent 21b30d6 commit 576ebd2

File tree

62 files changed

+9121
-223
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+9121
-223
lines changed

TODO.md

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,10 @@ if not food_id and not (food_name and calories):
4545

4646
see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource)
4747

48-
- exceptions.py
49-
50-
- Should ClientValidationException really subclass FitbitAPIException? IT
51-
SHOULD SUBCLASS ValueError doesn't need the API lookup mapping
52-
(`exception_type`) or a `status_code`, so we may just be able to simplify
53-
it. The most important thing is that the user understands that the message
54-
came from the client prior to the API call.
55-
56-
- Make sure we aren't using
57-
58-
- Make sure that `ClientValidationException` is getting used for arbitrary
59-
validations like
48+
- exceptions.py Consider:
49+
- Add automatic token refresh for ExpiredTokenException
50+
- Implement backoff and retry for RateLimitExceededException
51+
- Add retry with exponential backoff for transient errors (5xx)
6052

6153
## Longer term TODOs
6254

docs/VALIDATIONS_AND_EXCEPTIONS.md

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
# Input Validation and Error Handling
22

3-
Many method parameter arguments are validated before making API requests. The
4-
aim is to encapulate the HTTP API as much as possible and raise more helpfule
5-
exceptions before a bad request is executed. Understanding these validations and
6-
the exceptions that are raised by them (and elsewhere) will help you use this
7-
library correctly.
3+
Many method parameter arguments are validated **before making any API
4+
requests**. The aim is to encapsulate the HTTP API as much as possible and raise
5+
more helpful exceptions before a bad request is executed. This approach:
6+
7+
- Preserves your API rate limits by catching errors locally
8+
- Provides more specific and helpful error messages
9+
- Simplifies debugging by clearly separating client-side validation issues from
10+
API response issues
11+
12+
Understanding these validations and the exceptions that are raised by them (and
13+
elsewhere) will help you use this library correctly and efficiently.
814

915
## Input Validation
1016

@@ -167,9 +173,52 @@ except ValidationException as e:
167173

168174
## Exception Handling
169175

170-
There are many custom exceptions, When validation fails or other errors occur,
176+
There are many custom exceptions. When validation fails or other errors occur,
171177
the library raises specific exceptions that help identify the problem.
172178

179+
### Using Custom Validation Exceptions
180+
181+
Client validation exceptions (`ClientValidationException` and its subclasses)
182+
are raised *before* any API call is made. This means:
183+
184+
1. They reflect problems with your input parameters that can be detected locally
185+
2. No network requests have been initiated when these exceptions occur
186+
3. They help you fix issues before consuming API rate limits
187+
188+
This is in contrast to API exceptions (`FitbitAPIException` and its subclasses),
189+
which are raised in response to errors returned by the Fitbit API after a
190+
network request has been made.
191+
192+
When using this library, you'll want to catch the specific exception types for
193+
proper error handling:
194+
195+
```python
196+
from fitbit_client.exceptions import ParameterValidationException, MissingParameterException
197+
198+
try:
199+
# When parameters might be missing
200+
client.nutrition.create_food_goal(calories=None, intensity=None)
201+
except MissingParameterException as e:
202+
print(f"Missing parameter: {e.message}")
203+
204+
try:
205+
# When parameters might be invalid
206+
client.sleep.create_sleep_goals(min_duration=-10)
207+
except ParameterValidationException as e:
208+
print(f"Invalid parameter value for {e.field_name}: {e.message}")
209+
```
210+
211+
You can also catch the base class for all client validation exceptions:
212+
213+
```python
214+
from fitbit_client.exceptions import ClientValidationException
215+
216+
try:
217+
client.activity.create_activity_log(duration_millis=-100, start_time="12:00", date="2024-02-20")
218+
except ClientValidationException as e:
219+
print(f"Validation error: {e.message}")
220+
```
221+
173222
### ValidationException
174223

175224
Raised when input parameters do not meet requirements:
@@ -238,17 +287,49 @@ except RateLimitExceededException as e:
238287

239288
### Exception Properties
240289

241-
All exceptions provide these properties:
290+
API exceptions (`FitbitAPIException` and its subclasses) provide these
291+
properties:
242292

243293
- `message`: Human-readable error description
244294
- `status_code`: HTTP status code (if applicable)
245295
- `error_type`: Type of error from the API
246296
- `field_name`: Name of the invalid field (for validation errors)
247297

298+
Validation exceptions (`ClientValidationException` and its subclasses) provide:
299+
300+
- `message`: Human-readable error description
301+
- `field_name`: Name of the invalid field (for validation errors)
302+
303+
Specific validation exception subclasses provide additional properties:
304+
305+
- `InvalidDateException`: Adds `date_str` property with the invalid date string
306+
- `InvalidDateRangeException`: Adds `start_date`, `end_date`, `max_days`, and
307+
`resource_name` properties
308+
- `IntradayValidationException`: Adds `allowed_values` and `resource_name`
309+
properties
310+
- `ParameterValidationException`: Used for invalid parameter values (e.g.,
311+
negative where positive is required)
312+
- `MissingParameterException`: Used when required parameters are missing or
313+
parameter combinations are invalid
314+
248315
### Exception Hierarchy:
249316

250317
```
251318
Exception
319+
├── ValueError
320+
│ └── ClientValidationException # Superclass for validations that take place before
321+
│ │ # making a request
322+
│ ├── InvalidDateException # Raised when a date string is not in the correct
323+
│ │ # format or not a valid calendar date
324+
│ ├── InvalidDateRangeException # Raised when a date range is invalid (e.g., end is
325+
│ │ # before start, exceeds max days)
326+
│ ├── PaginationException # Raised when pagination parameters are invalid
327+
│ ├── IntradayValidationException # Raised when intraday request parameters are invalid
328+
│ ├── ParameterValidationException # Raised when a parameter value is invalid
329+
│ │ # (e.g., negative when positive required)
330+
│ └── MissingParameterException # Raised when required parameters are missing or
331+
│ # parameter combinations are invalid
332+
252333
└── FitbitAPIException # Base exception for all Fitbit API errors
253334
254335
├── OAuthException # Superclass for all authentication flow exceptions
@@ -257,23 +338,15 @@ Exception
257338
│ ├── InvalidTokenException # Raised when the OAuth token is invalid
258339
│ └── InvalidClientException # Raised when the client_id is invalid
259340
260-
├── RequestException # Superclass for all API request exceptions
261-
│ ├── InvalidRequestException # Raised when the request syntax is invalid
262-
│ ├── AuthorizationException # Raised when there are authorization-related errors
263-
│ ├── InsufficientPermissionsException # Raised when the application has insufficient permissions
264-
│ ├── InsufficientScopeException # Raised when the application is missing a required scope
265-
│ ├── NotFoundException # Raised when the requested resource does not exist
266-
│ ├── RateLimitExceededException # Raised when the application hits rate limiting quotas
267-
│ ├── SystemException # Raised when there is a system-level failure
268-
│ └── ValidationException # Raised when a request parameter is invalid or missing
269-
270-
└── ClientValidationException # Superclass for validations that take place before
271-
│ # making a request
272-
├── InvalidDateException # Raised when a date string is not in the correct
273-
│ # format or not a valid calendar date
274-
├── InvalidDateRangeException # Raised when a date range is invalid (e.g., end is
275-
│ # before start, exceeds max days)
276-
└── IntradayValidationException # Raised when intraday request parameters are invalid
341+
└── RequestException # Superclass for all API request exceptions
342+
├── InvalidRequestException # Raised when the request syntax is invalid
343+
├── AuthorizationException # Raised when there are authorization-related errors
344+
├── InsufficientPermissionsException # Raised when the application has insufficient permissions
345+
├── InsufficientScopeException # Raised when the application is missing a required scope
346+
├── NotFoundException # Raised when the requested resource does not exist
347+
├── RateLimitExceededException # Raised when the application hits rate limiting quotas
348+
├── SystemException # Raised when there is a system-level failure
349+
└── ValidationException # Raised when a request parameter is invalid or missing
277350
```
278351

279352
## Debugging

fitbit_client/__init__.py,cover

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# fitbit_client/__init__.py
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# fitbit_client/auth/__init__.py
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# fitbit_client/auth/callback_handler.py
2+
3+
# Standard library imports
4+
> from http.server import BaseHTTPRequestHandler
5+
> from http.server import HTTPServer
6+
> from logging import Logger
7+
> from logging import getLogger
8+
> from socket import socket
9+
> from typing import Any # Used only for type declarations, not in runtime code
10+
> from typing import Callable
11+
> from typing import Dict
12+
> from typing import List
13+
> from typing import Tuple
14+
> from typing import Type
15+
> from typing import TypeVar
16+
> from typing import Union
17+
> from urllib.parse import parse_qs
18+
> from urllib.parse import urlparse
19+
20+
# Local imports
21+
> from fitbit_client.exceptions import InvalidGrantException
22+
> from fitbit_client.exceptions import InvalidRequestException
23+
> from fitbit_client.utils.types import JSONDict
24+
25+
# Type variable for server
26+
> T = TypeVar("T", bound=HTTPServer)
27+
28+
29+
> class CallbackHandler(BaseHTTPRequestHandler):
30+
> """Handle OAuth2 callback requests"""
31+
32+
> logger: Logger
33+
34+
> def __init__(self, *args: Any, **kwargs: Any) -> None:
35+
> """Initialize the callback handler.
36+
37+
> The signature matches BaseHTTPRequestHandler's __init__ method:
38+
> __init__(self, request: Union[socket, Tuple[bytes, socket]],
39+
> client_address: Tuple[str, int],
40+
> server: HTTPServer)
41+
42+
> But we use *args, **kwargs to avoid type compatibility issues with the parent class.
43+
> """
44+
> self.logger = getLogger("fitbit_client.callback_handler")
45+
> super().__init__(*args, **kwargs)
46+
47+
> def parse_query_parameters(self) -> Dict[str, str]:
48+
> """Parse and validate query parameters from callback URL
49+
50+
> Returns:
51+
> Dictionary of parsed parameters with single values
52+
53+
> Raises:
54+
> InvalidRequestException: If required parameters are missing
55+
> InvalidGrantException: If authorization code is invalid/expired
56+
> """
57+
> query_components: Dict[str, List[str]] = parse_qs(urlparse(self.path).query)
58+
> self.logger.debug(f"Query parameters: {query_components}")
59+
60+
# Check for error response
61+
> if "error" in query_components:
62+
> error_type: str = query_components["error"][0]
63+
> error_desc: str = query_components.get("error_description", ["Unknown error"])[0]
64+
65+
> if error_type == "invalid_grant":
66+
> raise InvalidGrantException(
67+
> message=error_desc, status_code=400, error_type="invalid_grant"
68+
> )
69+
> else:
70+
> raise InvalidRequestException(
71+
> message=error_desc, status_code=400, error_type=error_type
72+
> )
73+
74+
# Check for required parameters
75+
> required_params: List[str] = ["code", "state"]
76+
> missing_params: List[str] = [
77+
> param for param in required_params if param not in query_components
78+
> ]
79+
> if missing_params:
80+
> raise InvalidRequestException(
81+
> message=f"Missing required parameters: {', '.join(missing_params)}",
82+
> status_code=400,
83+
> error_type="invalid_request",
84+
> field_name="callback_params",
85+
> )
86+
87+
# Convert from Dict[str, List[str]] to Dict[str, str] by taking first value of each
88+
> return {k: v[0] for k, v in query_components.items()}
89+
90+
> def send_success_response(self) -> None:
91+
> """Send successful authentication response to browser"""
92+
> self.send_response(200)
93+
> self.send_header("Content-Type", "text/html")
94+
> self.end_headers()
95+
96+
> response: str = """
97+
> <html>
98+
> <body>
99+
> <h1>Authentication Successful!</h1>
100+
> <p>You can close this window and return to your application.</p>
101+
> <script>setTimeout(() => window.close(), 5000);</script>
102+
> </body>
103+
> </html>
104+
> """
105+
106+
> self.wfile.write(response.encode("utf-8"))
107+
> self.logger.debug("Sent success response to browser")
108+
109+
> def send_error_response(self, error_message: str) -> None:
110+
> """Send error response to browser"""
111+
> self.send_response(400)
112+
> self.send_header("Content-Type", "text/html")
113+
> self.end_headers()
114+
115+
> response: str = f"""
116+
> <html>
117+
> <body>
118+
> <h1>Authentication Error</h1>
119+
> <p>{error_message}</p>
120+
> <p>You can close this window and try again.</p>
121+
> <script>setTimeout(() => window.close(), 10000);</script>
122+
> </body>
123+
> </html>
124+
> """
125+
126+
> self.wfile.write(response.encode("utf-8"))
127+
> self.logger.debug("Sent error response to browser")
128+
129+
> def do_GET(self) -> None:
130+
> """Process GET request and extract OAuth parameters
131+
132+
> This handles the OAuth2 callback, including:
133+
> - Parameter validation
134+
> - Error handling
135+
> - Success/error responses
136+
> - Storing callback data for the server
137+
> """
138+
> self.logger.debug(f"Received callback request: {self.path}")
139+
140+
> try:
141+
# Parse and validate query parameters
142+
> self.parse_query_parameters()
143+
144+
# Send success response
145+
> self.send_success_response()
146+
147+
# Store validated callback in server instance
148+
> setattr(self.server, "last_callback", self.path)
149+
> self.logger.debug("OAuth callback received and validated successfully")
150+
151+
> except (InvalidRequestException, InvalidGrantException) as e:
152+
# Send error response to browser
153+
> self.send_error_response(str(e))
154+
# Re-raise for server to handle
155+
> raise
156+
157+
> def log_message(self, format_str: str, *args: Union[str, int, float]) -> None:
158+
> """Override default logging to use our logger instead
159+
160+
> Args:
161+
> format_str: Format string for the log message
162+
> args: Values to be formatted into the string
163+
> """
164+
> self.logger.debug(f"Server log: {format_str % args}")

fitbit_client/auth/callback_server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def __init__(self, redirect_uri: str) -> None:
5454
raise InvalidRequestException(
5555
message="Request to invalid domain: redirect_uri must use HTTPS protocol.",
5656
status_code=400,
57-
error_type="request",
57+
error_type="invalid_request",
5858
field_name="redirect_uri",
5959
)
6060

@@ -237,9 +237,10 @@ def wait_for_callback(self, timeout: int = 300) -> Optional[str]:
237237

238238
self.logger.error("Callback wait timed out")
239239
raise InvalidRequestException(
240-
message="OAuth callback timed out waiting for response",
240+
message=f"OAuth callback timed out after {timeout} seconds",
241241
status_code=400,
242242
error_type="invalid_request",
243+
field_name="oauth_callback",
243244
)
244245

245246
def stop(self) -> None:

0 commit comments

Comments
 (0)