Skip to content

Commit d427e1f

Browse files
authored
Big exception refactor and simplification (#11)
* Big exception refactor and simplification * fix test coverage
1 parent 4c2cea8 commit d427e1f

25 files changed

+588
-211
lines changed

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/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:

fitbit_client/auth/oauth.py

Lines changed: 116 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ def __init__(
7373
raise InvalidRequestException(
7474
message="This request should use https protocol.",
7575
status_code=400,
76-
error_type="request",
76+
error_type="invalid_request",
77+
field_name="redirect_uri",
7778
)
7879

7980
environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
@@ -128,7 +129,14 @@ def _load_token(self) -> Optional[TokenDict]:
128129
except InvalidGrantException:
129130
# Invalid/expired refresh token
130131
return None
131-
except Exception:
132+
except json.JSONDecodeError:
133+
self.logger.error(f"Invalid JSON in token cache file: {self.token_cache_path}")
134+
return None
135+
except OSError as e:
136+
self.logger.error(f"Error reading token cache file: {self.token_cache_path}: {str(e)}")
137+
return None
138+
except Exception as e:
139+
self.logger.error(f"Unexpected error loading token: {e.__class__.__name__}: {str(e)}")
132140
return None
133141
return None
134142

@@ -139,7 +147,23 @@ def _save_token(self, token: TokenDict) -> None:
139147
self.token = token
140148

141149
def authenticate(self, force_new: bool = False) -> bool:
142-
"""Complete authentication flow if needed"""
150+
"""Complete authentication flow if needed
151+
152+
Args:
153+
force_new: Force new authentication even if valid token exists
154+
155+
Returns:
156+
bool: True if authenticated successfully
157+
158+
Raises:
159+
InvalidRequestException: If the request syntax is invalid
160+
InvalidClientException: If the client_id is invalid
161+
InvalidGrantException: If the grant_type is invalid
162+
InvalidTokenException: If the OAuth token is invalid
163+
ExpiredTokenException: If the OAuth token has expired
164+
OAuthException: Base class for all OAuth-related exceptions
165+
SystemException: If there's a system-level failure
166+
"""
143167
if not force_new and self.is_authenticated():
144168
self.logger.debug("Authentication token exchange completed successfully")
145169
return True
@@ -164,18 +188,9 @@ def authenticate(self, force_new: bool = False) -> bool:
164188
callback_url = input("Enter the full callback URL: ")
165189

166190
# Exchange authorization code for token
167-
try:
168-
token = self.fetch_token(callback_url)
169-
self._save_token(token)
170-
return True
171-
except Exception as e:
172-
if "invalid_grant" in str(e):
173-
raise InvalidGrantException(
174-
message="Authorization code expired or invalid",
175-
status_code=400,
176-
error_type="invalid_grant",
177-
) from e
178-
raise
191+
token = self.fetch_token(callback_url)
192+
self._save_token(token)
193+
return True
179194

180195
def is_authenticated(self) -> bool:
181196
"""Check if we have valid tokens"""
@@ -192,7 +207,21 @@ def get_authorization_url(self) -> Tuple[str, str]:
192207
return (str(auth_url_tuple[0]), str(auth_url_tuple[1]))
193208

194209
def fetch_token(self, authorization_response: str) -> TokenDict:
195-
"""Exchange authorization code for access token"""
210+
"""Exchange authorization code for access token
211+
212+
Args:
213+
authorization_response: The full callback URL with authorization code
214+
215+
Returns:
216+
TokenDict: Dictionary containing access token and other OAuth details
217+
218+
Raises:
219+
InvalidClientException: If the client credentials are invalid
220+
InvalidTokenException: If the authorization code is invalid
221+
InvalidGrantException: If the authorization grant is invalid
222+
ExpiredTokenException: If the token has expired
223+
OAuthException: For other OAuth-related errors
224+
"""
196225
try:
197226
auth = HTTPBasicAuth(self.client_id, self.client_secret)
198227
token_data = self.session.fetch_token(
@@ -208,31 +237,54 @@ def fetch_token(self, authorization_response: str) -> TokenDict:
208237
except Exception as e:
209238
error_msg = str(e).lower()
210239

211-
if "invalid_client" in error_msg:
212-
self.logger.error(
213-
f"InvalidClientException: Authentication failed "
214-
f"(Client ID: {self.client_id[:4]}..., Error: {str(e)})"
215-
)
216-
raise InvalidClientException(
217-
message="Invalid client credentials",
218-
status_code=401,
219-
error_type="invalid_client",
220-
) from e
221-
if "invalid_token" in error_msg:
222-
self.logger.error(
223-
f"InvalidTokenException: Token validation failed " f"(Error: {str(e)})"
224-
)
225-
raise InvalidTokenException(
226-
message="Invalid authorization code",
227-
status_code=401,
228-
error_type="invalid_token",
229-
) from e
240+
# Use standard error mapping from ERROR_TYPE_EXCEPTIONS
241+
# Local imports
242+
from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS
243+
from fitbit_client.exceptions import OAuthException
244+
245+
# Check for known error types
246+
for error_type, exception_class in ERROR_TYPE_EXCEPTIONS.items():
247+
if error_type in error_msg:
248+
# Special case for client ID to mask most of it in logs
249+
if error_type == "invalid_client":
250+
self.logger.error(
251+
f"{exception_class.__name__}: Authentication failed "
252+
f"(Client ID: {self.client_id[:4]}..., Error: {str(e)})"
253+
)
254+
else:
255+
self.logger.error(
256+
f"{exception_class.__name__}: {error_type} error during token fetch: {str(e)}"
257+
)
230258

231-
self.logger.error(f"OAuthException: {e.__class__.__name__}: {str(e)}")
232-
raise
259+
raise exception_class(
260+
message=str(e),
261+
status_code=(
262+
401 if "token" in error_type or error_type == "authorization" else 400
263+
),
264+
error_type=error_type,
265+
) from e
266+
267+
# If no specific error type found, use OAuthException
268+
self.logger.error(
269+
f"OAuthException during token fetch: {e.__class__.__name__}: {str(e)}"
270+
)
271+
raise OAuthException(message=str(e), status_code=400, error_type="oauth") from e
233272

234273
def refresh_token(self, refresh_token: str) -> TokenDict:
235-
"""Refresh the access token"""
274+
"""Refresh the access token
275+
276+
Args:
277+
refresh_token: The refresh token to use
278+
279+
Returns:
280+
TokenDict: Dictionary containing new access token and other OAuth details
281+
282+
Raises:
283+
ExpiredTokenException: If the access token has expired
284+
InvalidGrantException: If the refresh token is invalid
285+
InvalidClientException: If the client credentials are invalid
286+
OAuthException: For other OAuth-related errors
287+
"""
236288
try:
237289
auth = HTTPBasicAuth(self.client_id, self.client_secret)
238290
extra = {
@@ -248,12 +300,29 @@ def refresh_token(self, refresh_token: str) -> TokenDict:
248300
return token
249301
except Exception as e:
250302
error_msg = str(e).lower()
251-
if "expired_token" in error_msg:
252-
raise ExpiredTokenException(
253-
message="Access token expired", status_code=401, error_type="expired_token"
254-
) from e
255-
if "invalid_grant" in error_msg:
256-
raise InvalidGrantException(
257-
message="Refresh token invalid", status_code=400, error_type="invalid_grant"
258-
) from e
259-
raise
303+
304+
# Use standard error mapping from ERROR_TYPE_EXCEPTIONS
305+
# Local imports
306+
from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS
307+
from fitbit_client.exceptions import OAuthException
308+
309+
# Check for known error types
310+
for error_type, exception_class in ERROR_TYPE_EXCEPTIONS.items():
311+
if error_type in error_msg:
312+
self.logger.error(
313+
f"{exception_class.__name__}: {error_type} error during token refresh: {str(e)}"
314+
)
315+
316+
raise exception_class(
317+
message=str(e),
318+
status_code=(
319+
401 if "token" in error_type or error_type == "authorization" else 400
320+
),
321+
error_type=error_type,
322+
) from e
323+
324+
# If no specific error type found, use OAuthException
325+
self.logger.error(
326+
f"OAuthException during token refresh: {e.__class__.__name__}: {str(e)}"
327+
)
328+
raise OAuthException(message=str(e), status_code=400, error_type="oauth") from e

0 commit comments

Comments
 (0)