Skip to content

Commit

Permalink
Big exception refactor and simplification (#11)
Browse files Browse the repository at this point in the history
* Big exception refactor and simplification
* fix test coverage
  • Loading branch information
jpstroop authored Mar 4, 2025
1 parent 21b30d6 commit 576ebd2
Show file tree
Hide file tree
Showing 62 changed files with 9,121 additions and 223 deletions.
16 changes: 4 additions & 12 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,10 @@ if not food_id and not (food_name and calories):

see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource)

- exceptions.py

- Should ClientValidationException really subclass FitbitAPIException? IT
SHOULD SUBCLASS ValueError doesn't need the API lookup mapping
(`exception_type`) or a `status_code`, so we may just be able to simplify
it. The most important thing is that the user understands that the message
came from the client prior to the API call.

- Make sure we aren't using

- Make sure that `ClientValidationException` is getting used for arbitrary
validations like
- exceptions.py Consider:
- Add automatic token refresh for ExpiredTokenException
- Implement backoff and retry for RateLimitExceededException
- Add retry with exponential backoff for transient errors (5xx)

## Longer term TODOs

Expand Down
121 changes: 97 additions & 24 deletions docs/VALIDATIONS_AND_EXCEPTIONS.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# Input Validation and Error Handling

Many method parameter arguments are validated before making API requests. The
aim is to encapulate the HTTP API as much as possible and raise more helpfule
exceptions before a bad request is executed. Understanding these validations and
the exceptions that are raised by them (and elsewhere) will help you use this
library correctly.
Many method parameter arguments are validated **before making any API
requests**. The aim is to encapsulate the HTTP API as much as possible and raise
more helpful exceptions before a bad request is executed. This approach:

- Preserves your API rate limits by catching errors locally
- Provides more specific and helpful error messages
- Simplifies debugging by clearly separating client-side validation issues from
API response issues

Understanding these validations and the exceptions that are raised by them (and
elsewhere) will help you use this library correctly and efficiently.

## Input Validation

Expand Down Expand Up @@ -167,9 +173,52 @@ except ValidationException as e:

## Exception Handling

There are many custom exceptions, When validation fails or other errors occur,
There are many custom exceptions. When validation fails or other errors occur,
the library raises specific exceptions that help identify the problem.

### Using Custom Validation Exceptions

Client validation exceptions (`ClientValidationException` and its subclasses)
are raised *before* any API call is made. This means:

1. They reflect problems with your input parameters that can be detected locally
2. No network requests have been initiated when these exceptions occur
3. They help you fix issues before consuming API rate limits

This is in contrast to API exceptions (`FitbitAPIException` and its subclasses),
which are raised in response to errors returned by the Fitbit API after a
network request has been made.

When using this library, you'll want to catch the specific exception types for
proper error handling:

```python
from fitbit_client.exceptions import ParameterValidationException, MissingParameterException

try:
# When parameters might be missing
client.nutrition.create_food_goal(calories=None, intensity=None)
except MissingParameterException as e:
print(f"Missing parameter: {e.message}")

try:
# When parameters might be invalid
client.sleep.create_sleep_goals(min_duration=-10)
except ParameterValidationException as e:
print(f"Invalid parameter value for {e.field_name}: {e.message}")
```

You can also catch the base class for all client validation exceptions:

```python
from fitbit_client.exceptions import ClientValidationException

try:
client.activity.create_activity_log(duration_millis=-100, start_time="12:00", date="2024-02-20")
except ClientValidationException as e:
print(f"Validation error: {e.message}")
```

### ValidationException

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

### Exception Properties

All exceptions provide these properties:
API exceptions (`FitbitAPIException` and its subclasses) provide these
properties:

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

Validation exceptions (`ClientValidationException` and its subclasses) provide:

- `message`: Human-readable error description
- `field_name`: Name of the invalid field (for validation errors)

Specific validation exception subclasses provide additional properties:

- `InvalidDateException`: Adds `date_str` property with the invalid date string
- `InvalidDateRangeException`: Adds `start_date`, `end_date`, `max_days`, and
`resource_name` properties
- `IntradayValidationException`: Adds `allowed_values` and `resource_name`
properties
- `ParameterValidationException`: Used for invalid parameter values (e.g.,
negative where positive is required)
- `MissingParameterException`: Used when required parameters are missing or
parameter combinations are invalid

### Exception Hierarchy:

```
Exception
├── ValueError
│ └── ClientValidationException # Superclass for validations that take place before
│ │ # making a request
│ ├── InvalidDateException # Raised when a date string is not in the correct
│ │ # format or not a valid calendar date
│ ├── InvalidDateRangeException # Raised when a date range is invalid (e.g., end is
│ │ # before start, exceeds max days)
│ ├── PaginationException # Raised when pagination parameters are invalid
│ ├── IntradayValidationException # Raised when intraday request parameters are invalid
│ ├── ParameterValidationException # Raised when a parameter value is invalid
│ │ # (e.g., negative when positive required)
│ └── MissingParameterException # Raised when required parameters are missing or
│ # parameter combinations are invalid
└── FitbitAPIException # Base exception for all Fitbit API errors
├── OAuthException # Superclass for all authentication flow exceptions
Expand All @@ -257,23 +338,15 @@ Exception
│ ├── InvalidTokenException # Raised when the OAuth token is invalid
│ └── InvalidClientException # Raised when the client_id is invalid
├── RequestException # Superclass for all API request exceptions
│ ├── InvalidRequestException # Raised when the request syntax is invalid
│ ├── AuthorizationException # Raised when there are authorization-related errors
│ ├── InsufficientPermissionsException # Raised when the application has insufficient permissions
│ ├── InsufficientScopeException # Raised when the application is missing a required scope
│ ├── NotFoundException # Raised when the requested resource does not exist
│ ├── RateLimitExceededException # Raised when the application hits rate limiting quotas
│ ├── SystemException # Raised when there is a system-level failure
│ └── ValidationException # Raised when a request parameter is invalid or missing
└── ClientValidationException # Superclass for validations that take place before
│ # making a request
├── InvalidDateException # Raised when a date string is not in the correct
│ # format or not a valid calendar date
├── InvalidDateRangeException # Raised when a date range is invalid (e.g., end is
│ # before start, exceeds max days)
└── IntradayValidationException # Raised when intraday request parameters are invalid
└── RequestException # Superclass for all API request exceptions
├── InvalidRequestException # Raised when the request syntax is invalid
├── AuthorizationException # Raised when there are authorization-related errors
├── InsufficientPermissionsException # Raised when the application has insufficient permissions
├── InsufficientScopeException # Raised when the application is missing a required scope
├── NotFoundException # Raised when the requested resource does not exist
├── RateLimitExceededException # Raised when the application hits rate limiting quotas
├── SystemException # Raised when there is a system-level failure
└── ValidationException # Raised when a request parameter is invalid or missing
```

## Debugging
Expand Down
1 change: 1 addition & 0 deletions fitbit_client/__init__.py,cover
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# fitbit_client/__init__.py
1 change: 1 addition & 0 deletions fitbit_client/auth/__init__.py,cover
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# fitbit_client/auth/__init__.py
164 changes: 164 additions & 0 deletions fitbit_client/auth/callback_handler.py,cover
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# fitbit_client/auth/callback_handler.py

# Standard library imports
> from http.server import BaseHTTPRequestHandler
> from http.server import HTTPServer
> from logging import Logger
> from logging import getLogger
> from socket import socket
> from typing import Any # Used only for type declarations, not in runtime code
> from typing import Callable
> from typing import Dict
> from typing import List
> from typing import Tuple
> from typing import Type
> from typing import TypeVar
> from typing import Union
> from urllib.parse import parse_qs
> from urllib.parse import urlparse

# Local imports
> from fitbit_client.exceptions import InvalidGrantException
> from fitbit_client.exceptions import InvalidRequestException
> from fitbit_client.utils.types import JSONDict

# Type variable for server
> T = TypeVar("T", bound=HTTPServer)


> class CallbackHandler(BaseHTTPRequestHandler):
> """Handle OAuth2 callback requests"""

> logger: Logger

> def __init__(self, *args: Any, **kwargs: Any) -> None:
> """Initialize the callback handler.

> The signature matches BaseHTTPRequestHandler's __init__ method:
> __init__(self, request: Union[socket, Tuple[bytes, socket]],
> client_address: Tuple[str, int],
> server: HTTPServer)

> But we use *args, **kwargs to avoid type compatibility issues with the parent class.
> """
> self.logger = getLogger("fitbit_client.callback_handler")
> super().__init__(*args, **kwargs)

> def parse_query_parameters(self) -> Dict[str, str]:
> """Parse and validate query parameters from callback URL

> Returns:
> Dictionary of parsed parameters with single values

> Raises:
> InvalidRequestException: If required parameters are missing
> InvalidGrantException: If authorization code is invalid/expired
> """
> query_components: Dict[str, List[str]] = parse_qs(urlparse(self.path).query)
> self.logger.debug(f"Query parameters: {query_components}")

# Check for error response
> if "error" in query_components:
> error_type: str = query_components["error"][0]
> error_desc: str = query_components.get("error_description", ["Unknown error"])[0]

> if error_type == "invalid_grant":
> raise InvalidGrantException(
> message=error_desc, status_code=400, error_type="invalid_grant"
> )
> else:
> raise InvalidRequestException(
> message=error_desc, status_code=400, error_type=error_type
> )

# Check for required parameters
> required_params: List[str] = ["code", "state"]
> missing_params: List[str] = [
> param for param in required_params if param not in query_components
> ]
> if missing_params:
> raise InvalidRequestException(
> message=f"Missing required parameters: {', '.join(missing_params)}",
> status_code=400,
> error_type="invalid_request",
> field_name="callback_params",
> )

# Convert from Dict[str, List[str]] to Dict[str, str] by taking first value of each
> return {k: v[0] for k, v in query_components.items()}

> def send_success_response(self) -> None:
> """Send successful authentication response to browser"""
> self.send_response(200)
> self.send_header("Content-Type", "text/html")
> self.end_headers()

> response: str = """
> <html>
> <body>
> <h1>Authentication Successful!</h1>
> <p>You can close this window and return to your application.</p>
> <script>setTimeout(() => window.close(), 5000);</script>
> </body>
> </html>
> """

> self.wfile.write(response.encode("utf-8"))
> self.logger.debug("Sent success response to browser")

> def send_error_response(self, error_message: str) -> None:
> """Send error response to browser"""
> self.send_response(400)
> self.send_header("Content-Type", "text/html")
> self.end_headers()

> response: str = f"""
> <html>
> <body>
> <h1>Authentication Error</h1>
> <p>{error_message}</p>
> <p>You can close this window and try again.</p>
> <script>setTimeout(() => window.close(), 10000);</script>
> </body>
> </html>
> """

> self.wfile.write(response.encode("utf-8"))
> self.logger.debug("Sent error response to browser")

> def do_GET(self) -> None:
> """Process GET request and extract OAuth parameters

> This handles the OAuth2 callback, including:
> - Parameter validation
> - Error handling
> - Success/error responses
> - Storing callback data for the server
> """
> self.logger.debug(f"Received callback request: {self.path}")

> try:
# Parse and validate query parameters
> self.parse_query_parameters()

# Send success response
> self.send_success_response()

# Store validated callback in server instance
> setattr(self.server, "last_callback", self.path)
> self.logger.debug("OAuth callback received and validated successfully")

> except (InvalidRequestException, InvalidGrantException) as e:
# Send error response to browser
> self.send_error_response(str(e))
# Re-raise for server to handle
> raise

> def log_message(self, format_str: str, *args: Union[str, int, float]) -> None:
> """Override default logging to use our logger instead

> Args:
> format_str: Format string for the log message
> args: Values to be formatted into the string
> """
> self.logger.debug(f"Server log: {format_str % args}")
5 changes: 3 additions & 2 deletions fitbit_client/auth/callback_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def __init__(self, redirect_uri: str) -> None:
raise InvalidRequestException(
message="Request to invalid domain: redirect_uri must use HTTPS protocol.",
status_code=400,
error_type="request",
error_type="invalid_request",
field_name="redirect_uri",
)

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

self.logger.error("Callback wait timed out")
raise InvalidRequestException(
message="OAuth callback timed out waiting for response",
message=f"OAuth callback timed out after {timeout} seconds",
status_code=400,
error_type="invalid_request",
field_name="oauth_callback",
)

def stop(self) -> None:
Expand Down
Loading

0 comments on commit 576ebd2

Please sign in to comment.