diff --git a/.gitignore b/.gitignore index a290e48..e3d6aae 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,10 @@ dmypy.json .DS_Store # Project specific files -heart_rate_data.json \ No newline at end of file +heart_rate_data.json + +# Private Claude instructions and context +.claude_private +PROJECT_STATE.md +ARCHITECTURE.md +SESSION_NOTES.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2518b5d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# Ourapy Project Instructions + +## Meta Instructions - IMPORTANT +- **Continuously improve these instructions** as we develop the library +- **Proactively suggest updates** when you notice: + - Repeated patterns that should be documented + - Better ways to accomplish tasks + - Outdated practices that need updating + - Missing guidelines that would improve consistency +- **Remove or update** instructions that no longer apply +- **Add new sections** as we explore new features or adopt new practices +- **Track significant changes** in the Change Log section at the bottom +- Consider these instructions as living documentation that evolves with the project + +### When to Update CLAUDE.md +- After implementing a new feature that introduces patterns +- When we establish a new best practice through experience +- After resolving issues that could be prevented with better guidelines +- When external dependencies or tools change +- During code reviews when we identify improvement opportunities +- When adopting new state-of-the-art practices + +### Context File Strategy +**Session Startup Protocol**: When detecting context loss (new session, after auto-compact, or when asked about something I should know but don't), immediately read all context files in one batch: +1. PROJECT_STATE.md - Current status and active work +2. ARCHITECTURE.md - Technical decisions and critical context +3. SESSION_NOTES.md - Recent development history + +**During Session**: Keep these files updated as work progresses, but don't re-read unless detecting another context loss event (WHICH SHOULD BE RARE). + +**Mid-Session Re-reading Threshold**: Only re-read context files when ALL conditions met: +- Confidence I should know the answer: >85% +- Confidence in my actual answer: <25% +- Messages since last context read: >20 +- Alternative: Ask explicitly "Should I re-read context files?" when uncertain + +**Optimization**: Read all relevant context files at once rather than piecemeal to minimize token overhead. + +## Code Style +- Use 4 spaces for indentation (Python PEP 8) +- Always run flake8 before considering any task complete +- Maximum line length: 100 characters +- Use type hints for all function parameters and return values + +## Testing +- Write tests for all new functionality +- Run pytest before marking any task as complete +- Test files should mirror the source structure in the tests/ directory +- Use descriptive test names that explain what's being tested + +## Error Handling +- Use the custom exception hierarchy in oura_api_client.exceptions +- Always provide meaningful error messages +- Implement retry logic for transient failures (5xx, timeouts, connection errors) + +## Git Workflow +- Never commit directly unless explicitly asked +- Always check git status before making changes +- Create descriptive commit messages explaining the "why" not just the "what" + +## Documentation +- Update docstrings for any modified functions +- Use Google-style docstrings +- Include usage examples for public APIs + +## Project-Specific Commands +- Lint: `flake8 oura_api_client/ tests/` +- Test (parallel): `python -m pytest tests/ -n auto -v` +- Test (sequential): `python -m pytest tests/ -v` +- Test (specific): `python -m pytest tests/test_specific.py::TestClass::test_method -v` +- Type check: `mypy oura_api_client/` (if available) + +## API Design Principles +- Keep the client interface simple and intuitive +- Use Pydantic models for all API responses +- Maintain backward compatibility when possible + +## Common Patterns +- All API endpoints should go through the `_make_request` method +- Use the `build_query_params` utility for consistent parameter handling +- Follow the existing endpoint module pattern for new features + +## Debugging Test Failures +**IMPORTANT**: Use systematic approaches when tests fail, especially in CI environments. + +### Local Debugging Strategy +1. **Use parallel execution**: `pytest -n auto` for faster feedback +2. **Target specific failures**: `pytest -x --tb=short` (stop on first failure) +3. **Re-run only failed tests**: `pytest --lf` (last failed) +4. **Isolate by class/method**: `pytest tests/test_file.py::TestClass::test_method` +5. **Use short tracebacks**: `--tb=short` or `--tb=line` for cleaner output + +### CI Failure Investigation +- **Timeouts ≠ No failures**: Test timeouts can mask actual test failures +- **Ask for specific error output** when remote logs aren't accessible +- **Don't assume environmental issues** without evidence +- **Test locally first** with the same conditions when possible + +### Common Patterns to Watch For +- Mock incompatibilities after refactoring (e.g., `raise_for_status` vs `response.ok`) +- Import errors from new dependencies +- Pydantic model validation failures from API changes + +## Change Log +### 2025-06-21 +- Added Meta Instructions section to ensure continuous improvement of guidelines +- Added parallel testing with pytest-xdist for faster local development +- Added comprehensive debugging section with systematic test failure approaches +- Updated project commands to use parallel testing by default +- Added context file strategy with threshold-based re-reading to minimize token waste +- Implemented session continuity system with PROJECT_STATE.md, ARCHITECTURE.md, SESSION_NOTES.md +- Initial creation with sections for code style, testing, error handling, git workflow, documentation, commands, API design, and common patterns \ No newline at end of file diff --git a/oura_api_client/__init__.py b/oura_api_client/__init__.py index 74c3012..058ebe1 100644 --- a/oura_api_client/__init__.py +++ b/oura_api_client/__init__.py @@ -1,3 +1,31 @@ """Oura API Client - A Python library for the Oura Ring API.""" +from .api.client import OuraClient +from .exceptions import ( + OuraAPIError, + OuraAuthenticationError, + OuraAuthorizationError, + OuraNotFoundError, + OuraRateLimitError, + OuraServerError, + OuraClientError, + OuraConnectionError, + OuraTimeoutError +) +from .utils import RetryConfig + __version__ = "0.1.0" + +__all__ = [ + "OuraClient", + "OuraAPIError", + "OuraAuthenticationError", + "OuraAuthorizationError", + "OuraNotFoundError", + "OuraRateLimitError", + "OuraServerError", + "OuraClientError", + "OuraConnectionError", + "OuraTimeoutError", + "RetryConfig" +] diff --git a/oura_api_client/api/client.py b/oura_api_client/api/client.py index a017d96..21ca4cd 100644 --- a/oura_api_client/api/client.py +++ b/oura_api_client/api/client.py @@ -3,6 +3,9 @@ import requests from typing import Optional, Dict, Any +from ..exceptions import create_api_error, OuraConnectionError, OuraTimeoutError +from ..utils import RetryConfig, retry_with_backoff + from .heartrate import HeartRateEndpoints from .personal import PersonalEndpoints from .daily_activity import DailyActivity @@ -29,17 +32,19 @@ class OuraClient: BASE_URL = "https://api.ouraring.com/v2" - def __init__(self, access_token: str): + def __init__(self, access_token: str, retry_config: Optional[RetryConfig] = None): """Initialize the Oura client with an access token. Args: access_token (str): Your Oura API personal access token + retry_config (RetryConfig, optional): Configuration for retry behavior """ self.access_token = access_token self.headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } + self.retry_config = retry_config or RetryConfig() # Initialize endpoint modules self.heartrate = HeartRateEndpoints(self) @@ -67,6 +72,7 @@ def _make_request( endpoint: str, params: Optional[Dict[str, Any]] = None, method: str = "GET", + timeout: Optional[float] = 30.0, ) -> Dict[str, Any]: """Make a request to the Oura API. @@ -74,12 +80,13 @@ def _make_request( endpoint (str): The API endpoint to call (should start with /) params (dict, optional): Query parameters for the request method (str): HTTP method to use (default: GET) + timeout (float, optional): Request timeout in seconds Returns: dict: The JSON response from the API Raises: - requests.exceptions.RequestException: If the API request fails + OuraAPIError: If the API request fails with specific error details """ # Ensure endpoint starts with / if not endpoint.startswith('/'): @@ -91,10 +98,84 @@ def _make_request( url = f"{self.BASE_URL}{endpoint}" - if method.upper() == "GET": - response = requests.get(url, headers=self.headers, params=params) + # Wrap the actual request in retry logic if enabled + if self.retry_config.enabled: + return self._make_request_with_retry(url, method, params, timeout, endpoint) else: - raise ValueError(f"HTTP method {method} is not supported yet") + return self._make_single_request(url, method, params, timeout, endpoint) + + def _make_single_request( + self, + url: str, + method: str, + params: Optional[Dict[str, Any]], + timeout: Optional[float], + endpoint: str + ) -> Dict[str, Any]: + """Make a single HTTP request without retry logic. + + Args: + url: Full URL to request + method: HTTP method + params: Query parameters + timeout: Request timeout + endpoint: Original endpoint for error context + + Returns: + dict: The JSON response from the API + + Raises: + OuraAPIError: If the request fails + """ + try: + if method.upper() == "GET": + response = requests.get(url, headers=self.headers, params=params, timeout=timeout) + else: + raise ValueError(f"HTTP method {method} is not supported yet") + + # Check for HTTP errors + if not response.ok: + raise create_api_error(response, endpoint) - response.raise_for_status() - return response.json() + return response.json() + + except requests.exceptions.Timeout as e: + raise OuraTimeoutError(f"Request timed out after {timeout} seconds", endpoint=endpoint) from e + except requests.exceptions.ConnectionError as e: + raise OuraConnectionError(f"Failed to connect to API: {str(e)}", endpoint=endpoint) from e + except requests.exceptions.RequestException as e: + raise create_api_error(getattr(e, 'response', None), endpoint, str(e)) from e + + def _make_request_with_retry( + self, + url: str, + method: str, + params: Optional[Dict[str, Any]], + timeout: Optional[float], + endpoint: str + ) -> Dict[str, Any]: + """Make HTTP request with retry logic. + + Args: + url: Full URL to request + method: HTTP method + params: Query parameters + timeout: Request timeout + endpoint: Original endpoint for error context + + Returns: + dict: The JSON response from the API + + Raises: + OuraAPIError: If all retries fail + """ + @retry_with_backoff( + max_retries=self.retry_config.max_retries, + base_delay=self.retry_config.base_delay, + max_delay=self.retry_config.max_delay, + jitter=self.retry_config.jitter + ) + def make_request(): + return self._make_single_request(url, method, params, timeout, endpoint) + + return make_request() diff --git a/oura_api_client/exceptions.py b/oura_api_client/exceptions.py new file mode 100644 index 0000000..c80aa62 --- /dev/null +++ b/oura_api_client/exceptions.py @@ -0,0 +1,182 @@ +"""Custom exceptions for the Oura API client.""" + +from typing import Optional +import requests + + +class OuraAPIError(Exception): + """Base exception class for Oura API errors.""" + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response: Optional[requests.Response] = None, + endpoint: Optional[str] = None + ): + """Initialize OuraAPIError. + + Args: + message: Error message + status_code: HTTP status code if available + response: Original HTTP response object + endpoint: The API endpoint that failed + """ + super().__init__(message) + self.message = message + self.status_code = status_code + self.response = response + self.endpoint = endpoint + + def __str__(self) -> str: + """Return string representation of the error.""" + parts = [self.message] + if self.status_code: + parts.append(f"Status: {self.status_code}") + if self.endpoint: + parts.append(f"Endpoint: {self.endpoint}") + return " | ".join(parts) + + +class OuraAuthenticationError(OuraAPIError): + """Raised when authentication fails (401 Unauthorized).""" + pass + + +class OuraAuthorizationError(OuraAPIError): + """Raised when authorization fails (403 Forbidden).""" + pass + + +class OuraNotFoundError(OuraAPIError): + """Raised when a resource is not found (404 Not Found).""" + pass + + +class OuraRateLimitError(OuraAPIError): + """Raised when rate limit is exceeded (429 Too Many Requests).""" + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response: Optional[requests.Response] = None, + endpoint: Optional[str] = None, + retry_after: Optional[int] = None + ): + """Initialize OuraRateLimitError. + + Args: + message: Error message + status_code: HTTP status code + response: Original HTTP response object + endpoint: The API endpoint that failed + retry_after: Seconds to wait before retrying (from Retry-After header) + """ + super().__init__(message, status_code, response, endpoint) + self.retry_after = retry_after + + +class OuraServerError(OuraAPIError): + """Raised when server encounters an error (5xx status codes).""" + pass + + +class OuraClientError(OuraAPIError): + """Raised when client request is invalid (4xx status codes, except specific ones).""" + pass + + +class OuraConnectionError(OuraAPIError): + """Raised when connection to API fails.""" + pass + + +class OuraTimeoutError(OuraAPIError): + """Raised when request times out.""" + pass + + +def _extract_error_message(response: requests.Response, status_code: int) -> str: + """Extract error message from response. + + Args: + response: HTTP response object + status_code: HTTP status code + + Returns: + Error message string + """ + try: + error_data = response.json() + if isinstance(error_data, dict): + message = error_data.get('error', error_data.get('message', '')) + if message: + return message + except (ValueError, KeyError): + pass + return f"HTTP {status_code}: {response.reason}" + + +def _extract_retry_after(response: requests.Response) -> Optional[int]: + """Extract retry-after value from response headers. + + Args: + response: HTTP response object + + Returns: + Retry-after value in seconds, or None + """ + retry_after_header = response.headers.get('Retry-After') + if retry_after_header: + try: + return int(retry_after_header) + except ValueError: + pass + return None + + +def create_api_error( + response: requests.Response, + endpoint: Optional[str] = None, + message: Optional[str] = None +) -> OuraAPIError: + """Create appropriate OuraAPIError based on response status code. + + Args: + response: HTTP response object + endpoint: The API endpoint that failed + message: Custom error message (will be auto-generated if not provided) + + Returns: + Appropriate OuraAPIError subclass instance + """ + status_code = response.status_code + + # Get error message + if not message: + message = _extract_error_message(response, status_code) + + # Map status codes to exception classes + error_mapping = { + 401: OuraAuthenticationError, + 403: OuraAuthorizationError, + 404: OuraNotFoundError, + } + + # Check specific status codes first + if status_code in error_mapping: + return error_mapping[status_code](message, status_code, response, endpoint) + + # Handle rate limit error with retry-after + if status_code == 429: + retry_after = _extract_retry_after(response) + return OuraRateLimitError(message, status_code, response, endpoint, retry_after) + + # Handle ranges + if 400 <= status_code < 500: + return OuraClientError(message, status_code, response, endpoint) + elif 500 <= status_code < 600: + return OuraServerError(message, status_code, response, endpoint) + else: + return OuraAPIError(message, status_code, response, endpoint) diff --git a/oura_api_client/models/daily_sleep.py b/oura_api_client/models/daily_sleep.py index ed274b9..038c10f 100644 --- a/oura_api_client/models/daily_sleep.py +++ b/oura_api_client/models/daily_sleep.py @@ -1,14 +1,10 @@ from pydantic import BaseModel, Field from typing import List, Optional - from datetime import date, datetime - class SleepContributors(BaseModel): - deep_sleep: Optional[int] = Field( - None, alias="deep_sleep" - ) + deep_sleep: Optional[int] = Field(None, alias="deep_sleep") efficiency: Optional[int] = Field(None, alias="efficiency") latency: Optional[int] = Field(None, alias="latency") rem_sleep: Optional[int] = Field(None, alias="rem_sleep") # REM sleep in minutes @@ -17,112 +13,38 @@ class SleepContributors(BaseModel): total_sleep: Optional[int] = Field(None, alias="total_sleep") # Total sleep in minutes - - - - class DailySleepModel(BaseModel): id: str contributors: SleepContributors day: date timestamp: datetime - score: Optional[int] = Field( - - - - - None, alias="score" - - - - -) + score: Optional[int] = Field(None, alias="score") bedtime_end: Optional[datetime] = Field(None, alias="bedtime_end") bedtime_start: Optional[datetime] = Field(None, alias="bedtime_start") breath_average: Optional[float] = Field(None, alias="breath_average") - deep_sleep_duration: Optional[int] = Field( - - None, alias="deep_sleep_duration" - - ) + deep_sleep_duration: Optional[int] = Field(None, alias="deep_sleep_duration") efficiency: Optional[int] = Field(None, alias="efficiency") - heart_rate_average: Optional[float] = Field( - - None, alias="heart_rate_average" - - ) - heart_rate_lowest: Optional[float] = Field( - - None, alias="heart_rate_lowest" - - ) + heart_rate_average: Optional[float] = Field(None, alias="heart_rate_average") + heart_rate_lowest: Optional[float] = Field(None, alias="heart_rate_lowest") hypnogram_5_min: Optional[str] = Field(None, alias="hypnogram_5_min") latency: Optional[int] = Field(None, alias="latency") - light_sleep_duration: Optional[int] = Field( - - None, alias="light_sleep_duration" - - ) + light_sleep_duration: Optional[int] = Field(None, alias="light_sleep_duration") low_battery_alert: Optional[bool] = Field(None, alias="low_battery_alert") - readiness_score_delta: Optional[int] = Field( - - None, alias="readiness_score_delta" - - ) - rem_sleep_duration: Optional[int] = Field( - - None, alias="rem_sleep_duration" - - ) + readiness_score_delta: Optional[int] = Field(None, alias="readiness_score_delta") + rem_sleep_duration: Optional[int] = Field(None, alias="rem_sleep_duration") restless_periods: Optional[int] = Field(None, alias="restless_periods") - sleep_phase_5_min: Optional[str] = Field( - - None, alias="sleep_phase_5_min" - - ) # Deprecated + sleep_phase_5_min: Optional[str] = Field(None, alias="sleep_phase_5_min") # Deprecated time_in_bed: Optional[int] = Field(None, alias="time_in_bed") - total_sleep_duration: Optional[int] = Field( - - None, alias="total_sleep_duration" - - ) - type: Optional[str] = Field( - - None, alias="type" - - ) # Enum: "deleted", "long_sleep", "main_sleep", "nap", "rest" + total_sleep_duration: Optional[int] = Field(None, alias="total_sleep_duration") + type: Optional[str] = Field(None, alias="type") # Enum: "deleted", "long_sleep", "main_sleep", "nap", "rest" average_hrv: Optional[float] = Field(None, alias="average_hrv") awake_time: Optional[int] = Field(None, alias="awake_time") - hr_60_second_average: Optional[List[int]] = Field( - - None, alias="hr_60_second_average" - - ) # New in v2.10 - hrv_4_hour_average: Optional[List[float]] = Field( - - None, alias="hrv_4_hour_average" - - ) # New in v2.10 - readiness: Optional[str] = Field( - - None, alias="readiness" - - ) # New in v2.10, but type not specified, assuming string for now - temperature_delta: Optional[float] = Field( - - None, alias="temperature_delta" - - ) - temperature_deviation: Optional[float] = Field( - - None, alias="temperature_deviation" - - ) # Deprecated - temperature_trend_deviation: Optional[float] = Field( - - None, alias="temperature_trend_deviation" - - ) + hr_60_second_average: Optional[List[int]] = Field(None, alias="hr_60_second_average") # New in v2.10 + hrv_4_hour_average: Optional[List[float]] = Field(None, alias="hrv_4_hour_average") # New in v2.10 + readiness: Optional[str] = Field(None, alias="readiness") # New in v2.10, but type not specified, assuming string for now + temperature_delta: Optional[float] = Field(None, alias="temperature_delta") + temperature_deviation: Optional[float] = Field(None, alias="temperature_deviation") # Deprecated + temperature_trend_deviation: Optional[float] = Field(None, alias="temperature_trend_deviation") class DailySleepResponse(BaseModel): diff --git a/oura_api_client/models/heartrate.py b/oura_api_client/models/heartrate.py index a1c379f..ccd0fa2 100644 --- a/oura_api_client/models/heartrate.py +++ b/oura_api_client/models/heartrate.py @@ -1,15 +1,13 @@ """Models for heart rate data.""" -from dataclasses import dataclass +from pydantic import BaseModel from typing import List, Optional from datetime import datetime -@dataclass - -class HeartRateSample: +class HeartRateSample(BaseModel): """Represents a single heart rate data point.""" - + timestamp: datetime bpm: int source: str @@ -17,39 +15,36 @@ class HeartRateSample: @classmethod def from_dict(cls, data: dict) -> "HeartRateSample": """Create a HeartRateSample from API response dictionary. - + + Note: This method is kept for backward compatibility. + Pydantic can parse directly from dict using HeartRateSample(**data) + Args: data: Dictionary containing heart rate data - + Returns: HeartRateSample: Instantiated object """ - return cls( - timestamp=datetime.fromisoformat(data["timestamp"]), - bpm=data["bpm"], - source=data["source"], - ) + return cls(**data) -@dataclass - -class HeartRateResponse: +class HeartRateResponse(BaseModel): """Represents the full heart rate response.""" - + data: List[HeartRateSample] next_token: Optional[str] = None @classmethod def from_dict(cls, response: dict) -> "HeartRateResponse": """Create a HeartRateResponse from API response dictionary. - + + Note: This method is kept for backward compatibility. + Pydantic can parse directly from dict using HeartRateResponse(**response) + Args: response: Dictionary containing API response - + Returns: HeartRateResponse: Instantiated object """ - return cls( - data=[HeartRateSample.from_dict(item) for item in response.get("data", [])], - next_token=response.get("next_token"), - ) + return cls(**response) diff --git a/oura_api_client/models/personal.py b/oura_api_client/models/personal.py index 3ca6817..f00638e 100644 --- a/oura_api_client/models/personal.py +++ b/oura_api_client/models/personal.py @@ -1,15 +1,13 @@ """Models for personal information data.""" -from dataclasses import dataclass +from pydantic import BaseModel from typing import Optional from datetime import date -@dataclass - -class PersonalInfo: +class PersonalInfo(BaseModel): """Represents personal information for a user.""" - + id: str email: str age: int @@ -21,23 +19,14 @@ class PersonalInfo: @classmethod def from_dict(cls, data: dict) -> "PersonalInfo": """Create a PersonalInfo object from API response dictionary. - + + Note: This method is kept for backward compatibility. + Pydantic can parse directly from dict using PersonalInfo(**data) + Args: data: Dictionary containing personal info data - + Returns: PersonalInfo: Instantiated object """ - birth_date = None - if data.get("birth_date"): - birth_date = date.fromisoformat(data["birth_date"]) - - return cls( - id=data["id"], - email=data["email"], - age=data["age"], - weight=data.get("weight"), - height=data.get("height"), - biological_sex=data.get("biological_sex"), - birth_date=birth_date, - ) + return cls(**data) diff --git a/oura_api_client/models/sleep.py b/oura_api_client/models/sleep.py index f31ce8e..dd1350b 100644 --- a/oura_api_client/models/sleep.py +++ b/oura_api_client/models/sleep.py @@ -1,123 +1,64 @@ from pydantic import BaseModel, Field from typing import List, Optional -from datetime import date, datetime # Added date -from oura_api_client.models.daily_readiness import ReadinessContributors # Reusing ReadinessContributors -from oura_api_client.models.daily_sleep import SleepContributors # Reusing SleepContributors +from datetime import date, datetime -class SleepModel(BaseModel): - id: str - average_breath: Optional[float] = Field( - None, alias="average_breath" - ) # New based on common sleep metrics - average_heart_rate: Optional[float] = Field( +class SleepContributors(BaseModel): + """Sleep contributors model for sleep data.""" + deep_sleep: Optional[int] = Field(None, alias="deep_sleep") + efficiency: Optional[int] = Field(None, alias="efficiency") + latency: Optional[int] = Field(None, alias="latency") + rem_sleep: Optional[int] = Field(None, alias="rem_sleep") + restfulness: Optional[int] = Field(None, alias="restfulness") + timing: Optional[int] = Field(None, alias="timing") + total_sleep: Optional[int] = Field(None, alias="total_sleep") - None, alias="average_heart_rate" - ) - average_hrv: Optional[int] = Field( +class ReadinessContributors(BaseModel): + """Readiness contributors model for sleep data.""" + activity_balance: Optional[int] = Field(None, alias="activity_balance") + body_temperature: Optional[int] = Field(None, alias="body_temperature") + hrv_balance: Optional[int] = Field(None, alias="hrv_balance") + previous_day_activity: Optional[int] = Field(None, alias="previous_day_activity") + previous_night: Optional[int] = Field(None, alias="previous_night") + recovery_index: Optional[int] = Field(None, alias="recovery_index") + resting_heart_rate: Optional[int] = Field(None, alias="resting_heart_rate") + sleep_balance: Optional[int] = Field(None, alias="sleep_balance") - None, alias="average_hrv" - ) # Changed type to int based on typical HRV units +class SleepModel(BaseModel): + id: str + average_breath: Optional[float] = Field(None, alias="average_breath") + average_heart_rate: Optional[float] = Field(None, alias="average_heart_rate") + average_hrv: Optional[int] = Field(None, alias="average_hrv") awake_time: Optional[int] = Field(None, alias="awake_time") bedtime_end: Optional[datetime] = Field(None, alias="bedtime_end") bedtime_start: Optional[datetime] = Field(None, alias="bedtime_start") - day: date # Added day - deep_sleep_duration: Optional[int] = Field( - - None, alias="deep_sleep_duration" - - ) + day: date + deep_sleep_duration: Optional[int] = Field(None, alias="deep_sleep_duration") efficiency: Optional[int] = Field(None, alias="efficiency") - heart_rate: Optional[str] = Field( - - None, alias="heart_rate" - - ) # Assuming string for heart_rate, adjust if it's a more complex type - hrv: Optional[str] = Field( - - None, alias="hrv" - - ) # Assuming string for hrv, adjust if it's a more complex type + heart_rate: Optional[str] = Field(None, alias="heart_rate") + hrv: Optional[str] = Field(None, alias="hrv") latency: Optional[int] = Field(None, alias="latency") - light_sleep_duration: Optional[int] = Field( - - None, alias="light_sleep_duration" - - ) + light_sleep_duration: Optional[int] = Field(None, alias="light_sleep_duration") low_battery_alert: Optional[bool] = Field(None, alias="low_battery_alert") - lowest_heart_rate: Optional[int] = Field( - - None, alias="lowest_heart_rate" - - ) # Changed type to int + lowest_heart_rate: Optional[int] = Field(None, alias="lowest_heart_rate") movement_30_sec: Optional[str] = Field(None, alias="movement_30_sec") period: Optional[int] = Field(None, alias="period") - readiness: Optional[ReadinessContributors] = Field( - - None, alias="readiness" - - ) # Reused ReadinessContributors - readiness_score_delta: Optional[int] = Field( - - None, alias="readiness_score_delta" - - ) - rem_sleep_duration: Optional[int] = Field( - - None, alias="rem_sleep_duration" - - ) - restless_periods: Optional[int] = Field( - - None, alias="restless_periods" - - ) # Added from daily_sleep - # score is usually part of daily summaries, but can be part of a detailed sleep document - score: Optional[int] = Field( - - None, alias="score" - - ) + readiness: Optional[ReadinessContributors] = Field(None, alias="readiness") + readiness_score_delta: Optional[int] = Field(None, alias="readiness_score_delta") + rem_sleep_duration: Optional[int] = Field(None, alias="rem_sleep_duration") + restless_periods: Optional[int] = Field(None, alias="restless_periods") + score: Optional[int] = Field(None, alias="score") sleep_phase_5_min: Optional[str] = Field(None, alias="sleep_phase_5_min") - sleep_score_delta: Optional[int] = Field( - - None, alias="sleep_score_delta" - - ) # New, similar to readiness_score_delta - sleep_algorithm_version: Optional[str] = Field( - - None, alias="sleep_algorithm_version" - - ) # New - temperature_delta: Optional[float] = Field( - - None, alias="temperature_delta" - - ) - temperature_deviation: Optional[float] = Field( - - None, alias="temperature_deviation" - - ) # Deprecated in daily_readiness - temperature_trend_deviation: Optional[float] = Field( - - None, alias="temperature_trend_deviation" - - ) # From daily_readiness + sleep_score_delta: Optional[int] = Field(None, alias="sleep_score_delta") + sleep_algorithm_version: Optional[str] = Field(None, alias="sleep_algorithm_version") + temperature_delta: Optional[float] = Field(None, alias="temperature_delta") + temperature_deviation: Optional[float] = Field(None, alias="temperature_deviation") + temperature_trend_deviation: Optional[float] = Field(None, alias="temperature_trend_deviation") time_in_bed: Optional[int] = Field(None, alias="time_in_bed") - total_sleep_duration: Optional[int] = Field( - - None, alias="total_sleep_duration" - - ) - type: Optional[str] = Field( - - None, alias="type" - - ) # From daily_sleep (e.g. "main_sleep", "nap") - # contributors from daily_sleep.py, as requested by the task + total_sleep_duration: Optional[int] = Field(None, alias="total_sleep_duration") + type: Optional[str] = Field(None, alias="type") contributors: SleepContributors diff --git a/oura_api_client/utils/__init__.py b/oura_api_client/utils/__init__.py index 30931eb..4382d17 100644 --- a/oura_api_client/utils/__init__.py +++ b/oura_api_client/utils/__init__.py @@ -1,5 +1,13 @@ """Utility functions for the Oura API client.""" from .query_params import build_query_params, convert_date_to_string +from .retry import RetryConfig, retry_with_backoff, should_retry, exponential_backoff -__all__ = ["build_query_params", "convert_date_to_string"] +__all__ = [ + "build_query_params", + "convert_date_to_string", + "RetryConfig", + "retry_with_backoff", + "should_retry", + "exponential_backoff" +] diff --git a/oura_api_client/utils/retry.py b/oura_api_client/utils/retry.py new file mode 100644 index 0000000..827b7ad --- /dev/null +++ b/oura_api_client/utils/retry.py @@ -0,0 +1,139 @@ +"""Retry utilities for handling transient failures.""" + +import time +import random +from typing import Callable +from ..exceptions import OuraRateLimitError, OuraServerError, OuraConnectionError, OuraTimeoutError + + +def exponential_backoff(attempt: int, base_delay: float = 1.0, max_delay: float = 60.0, jitter: bool = True) -> float: + """Calculate exponential backoff delay. + + Args: + attempt: Current attempt number (0-based) + base_delay: Base delay in seconds + max_delay: Maximum delay in seconds + jitter: Whether to add random jitter + + Returns: + Delay in seconds + """ + delay = base_delay * (2 ** attempt) + delay = min(delay, max_delay) + + if jitter: + # Add ±25% jitter + jitter_range = delay * 0.25 + delay += random.uniform(-jitter_range, jitter_range) + + return max(0, delay) + + +def should_retry(exception: Exception, attempt: int, max_retries: int) -> bool: + """Determine if an exception should trigger a retry. + + Args: + exception: The exception that occurred + attempt: Current attempt number (0-based) + max_retries: Maximum number of retries allowed + + Returns: + True if should retry, False otherwise + """ + if attempt >= max_retries: + return False + + # Retry on specific transient errors + if isinstance(exception, (OuraServerError, OuraConnectionError, OuraTimeoutError)): + return True + + # Retry on rate limit errors if retry_after is reasonable + if isinstance(exception, OuraRateLimitError): + if exception.retry_after and exception.retry_after <= 300: # Max 5 minutes + return True + elif not exception.retry_after: # No retry-after header, use exponential backoff + return True + + return False + + +def retry_with_backoff( + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 60.0, + jitter: bool = True +): + """Decorator factory to add retry logic with exponential backoff. + + Args: + max_retries: Maximum number of retries + base_delay: Base delay for exponential backoff + max_delay: Maximum delay between retries + jitter: Whether to add random jitter + + Returns: + Decorator function + """ + def decorator(func: Callable) -> Callable: + """Actual decorator that wraps the function. + + Args: + func: Function to wrap + + Returns: + Wrapped function with retry logic + """ + def wrapper(*args, **kwargs): + last_exception = None + + for attempt in range(max_retries + 1): # +1 for initial attempt + try: + return func(*args, **kwargs) + except Exception as e: + last_exception = e + + if not should_retry(e, attempt, max_retries): + raise + + # Calculate delay + if isinstance(e, OuraRateLimitError) and e.retry_after: + delay = e.retry_after + else: + delay = exponential_backoff(attempt, base_delay, max_delay, jitter) + + # Wait before retry + if delay > 0: + time.sleep(delay) + + # If we get here, all retries failed + raise last_exception + + return wrapper + return decorator + + +class RetryConfig: + """Configuration for retry behavior.""" + + def __init__( + self, + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 60.0, + jitter: bool = True, + enabled: bool = True + ): + """Initialize retry configuration. + + Args: + max_retries: Maximum number of retries + base_delay: Base delay for exponential backoff + max_delay: Maximum delay between retries + jitter: Whether to add random jitter + enabled: Whether retry is enabled + """ + self.max_retries = max_retries + self.base_delay = base_delay + self.max_delay = max_delay + self.jitter = jitter + self.enabled = enabled diff --git a/parse_openapi.py b/parse_openapi.py index 55059bf..d56f84a 100644 --- a/parse_openapi.py +++ b/parse_openapi.py @@ -1,6 +1,70 @@ import json import logging + +def _parse_method_details(method_details): + """Parse details for a single HTTP method.""" + method_data = { + "tags": method_details.get("tags", []), + "summary": method_details.get("summary"), + "operationId": method_details.get("operationId"), + "parameters": [], + "responses": {} + } + + # Parse parameters + if "parameters" in method_details and isinstance(method_details["parameters"], list): + for param in method_details["parameters"]: + method_data["parameters"].append({ + "name": param.get("name"), + "in": param.get("in"), + "required": param.get("required"), + "schema": param.get("schema") + }) + + # Parse responses + if "responses" in method_details and isinstance(method_details["responses"], dict): + for status_code, response_details in method_details["responses"].items(): + method_data["responses"][status_code] = { + "description": response_details.get("description"), + "content": response_details.get("content", {}).get("application/json", {}).get("schema") + } + + return method_data + + +def _parse_paths(spec): + """Parse paths section of OpenAPI spec.""" + paths_data = {} + + if "paths" not in spec or not isinstance(spec["paths"], dict): + print("Warning: 'paths' attribute not found or is not a dictionary in the OpenAPI spec.") + return paths_data + + for path_string, path_item in spec["paths"].items(): + paths_data[path_string] = {} + for method, method_details in path_item.items(): + if method.upper() in ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]: + paths_data[path_string][method.upper()] = _parse_method_details(method_details) + + return paths_data + + +def _parse_schemas(spec): + """Parse component schemas section of OpenAPI spec.""" + components_schemas_data = {} + + if ("components" in spec and + "schemas" in spec["components"] and + isinstance(spec["components"]["schemas"], dict)): + for schema_name, schema_details in spec["components"]["schemas"].items(): + components_schemas_data[schema_name] = schema_details + else: + print("Warning: 'components.schemas' attribute not found or is not a dictionary in the OpenAPI spec.") + + return components_schemas_data + + def parse_openapi_spec(spec_content): """ Parses the OpenAPI specification and extracts relevant information. @@ -19,44 +83,8 @@ def parse_openapi_spec(spec_content): logging.error(f"Error decoding JSON: {e}") raise ValueError(f"Failed to parse OpenAPI spec: {e}") - paths_data = {} - if "paths" in spec and isinstance(spec["paths"], dict): - for path_string, path_item in spec["paths"].items(): - paths_data[path_string] = {} - for method, method_details in path_item.items(): - if method.upper() in ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]: - paths_data[path_string][method.upper()] = { - "tags": method_details.get("tags", []), - "summary": method_details.get("summary"), - "operationId": method_details.get("operationId"), - "parameters": [], - "responses": {} - } - - if "parameters" in method_details and isinstance(method_details["parameters"], list): - for param in method_details["parameters"]: - paths_data[path_string][method.upper()]["parameters"].append({ - "name": param.get("name"), - "in": param.get("in"), - "required": param.get("required"), - "schema": param.get("schema") - }) - - if "responses" in method_details and isinstance(method_details["responses"], dict): - for status_code, response_details in method_details["responses"].items(): - paths_data[path_string][method.upper()]["responses"][status_code] = { - "description": response_details.get("description"), - "content": response_details.get("content", {}).get("application/json", {}).get("schema") - } - else: - print("Warning: 'paths' attribute not found or is not a dictionary in the OpenAPI spec.") - - components_schemas_data = {} - if "components" in spec and "schemas" in spec["components"] and isinstance(spec["components"]["schemas"], dict): - for schema_name, schema_details in spec["components"]["schemas"].items(): - components_schemas_data[schema_name] = schema_details - else: - print("Warning: 'components.schemas' attribute not found or is not a dictionary in the OpenAPI spec.") + paths_data = _parse_paths(spec) + components_schemas_data = _parse_schemas(spec) return paths_data, components_schemas_data diff --git a/requirements-dev.txt b/requirements-dev.txt index 5d79d62..1e1145b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,7 @@ -r requirements.txt pytest>=7.0.0 pytest-cov>=4.0.0 +pytest-xdist>=3.0.0 black>=23.0.0 isort>=5.12.0 flake8>=6.0.0 diff --git a/tests/test_client.py b/tests/test_client.py index 5c107fe..0b3be57 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,12 +10,12 @@ DailyActivityResponse, DailyActivityModel, ActivityContributors ) from oura_api_client.models.daily_sleep import ( - DailySleepResponse, DailySleepModel, SleepContributors + DailySleepResponse, DailySleepModel, SleepContributors as DailySleepContributors ) from oura_api_client.models.daily_readiness import ( - DailyReadinessResponse, DailyReadinessModel, ReadinessContributors + DailyReadinessResponse, DailyReadinessModel, ReadinessContributors as DailyReadinessContributors ) -from oura_api_client.models.sleep import SleepResponse, SleepModel +from oura_api_client.models.sleep import SleepResponse, SleepModel, SleepContributors, ReadinessContributors from oura_api_client.models.session import SessionResponse, SessionModel from oura_api_client.models.tag import TagResponse, TagModel from oura_api_client.models.workout import WorkoutResponse, WorkoutModel @@ -45,7 +45,11 @@ Vo2MaxResponse, Vo2MaxModel ) # Added Vo2Max models import requests -from requests.exceptions import RequestException + +from oura_api_client.exceptions import ( + OuraNotFoundError, OuraRateLimitError, + OuraClientError, OuraConnectionError +) class TestOuraClient(unittest.TestCase): """Test the OuraClient class.""" @@ -120,6 +124,7 @@ def test_get_heart_rate(self, mock_get): "https://api.ouraring.com/v2/usercollection/heartrate", headers=self.client.headers, params={"start_date": "2024-03-01", "end_date": "2024-03-15"}, + timeout=30.0, ) if __name__ == "__main__": @@ -224,8 +229,8 @@ def test_get_daily_activity_documents_with_string_dates(self, mock_get): @patch("requests.get") def test_get_daily_activity_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.daily_activity.get_daily_activity_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -291,9 +296,9 @@ def test_get_daily_activity_document(self, mock_get): @patch("requests.get") def test_get_daily_activity_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_document_id" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.daily_activity.get_daily_activity_document(document_id=document_id) class TestDailySleep(unittest.TestCase): @@ -357,7 +362,7 @@ def test_get_daily_sleep_documents(self, mock_get): self.assertIsInstance(daily_sleep_response.data[0], DailySleepModel) self.assertIsInstance( daily_sleep_response.data[0].contributors, - SleepContributors + DailySleepContributors ) self.assertEqual(daily_sleep_response.next_token, "next_sleep_token") @@ -369,7 +374,7 @@ def test_get_daily_sleep_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_sleep_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -399,13 +404,13 @@ def test_get_daily_sleep_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/daily_sleep", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_daily_sleep_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.daily_sleep.get_daily_sleep_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -443,7 +448,7 @@ def test_get_daily_sleep_document(self, mock_get): self.assertIsInstance(daily_sleep_document, DailySleepModel) self.assertEqual(daily_sleep_document.id, document_id) self.assertIsInstance( - daily_sleep_document.contributors, SleepContributors + daily_sleep_document.contributors, DailySleepContributors ) self.assertEqual(daily_sleep_document.score, 85) self.assertEqual( @@ -459,14 +464,14 @@ def test_get_daily_sleep_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/daily_sleep/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_daily_sleep_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_sleep_document_id" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.daily_sleep.get_daily_sleep_document(document_id=document_id) class TestDailyReadiness(unittest.TestCase): @@ -518,7 +523,7 @@ def test_get_daily_readiness_documents(self, mock_get): ) self.assertIsInstance( daily_readiness_response.data[0].contributors, - ReadinessContributors + DailyReadinessContributors ) self.assertEqual( daily_readiness_response.next_token, "next_readiness_token" @@ -532,7 +537,7 @@ def test_get_daily_readiness_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_readiness_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -563,13 +568,13 @@ def test_get_daily_readiness_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/daily_readiness", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_daily_readiness_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.daily_readiness.get_daily_readiness_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -612,7 +617,7 @@ def test_get_daily_readiness_document(self, mock_get): self.assertEqual(daily_readiness_document.id, document_id) self.assertIsInstance( daily_readiness_document.contributors, - ReadinessContributors + DailyReadinessContributors ) self.assertEqual(daily_readiness_document.score, 78) self.assertEqual( @@ -626,14 +631,14 @@ def test_get_daily_readiness_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/daily_readiness/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_daily_readiness_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_readiness_document_id" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.daily_readiness.get_daily_readiness_document( document_id=document_id ) @@ -724,7 +729,7 @@ def test_get_sleep_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_sleep_doc_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -753,13 +758,13 @@ def test_get_sleep_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/sleep", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_sleep_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.sleep.get_sleep_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -804,14 +809,14 @@ def test_get_sleep_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/sleep/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_sleep_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_sleep_doc_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.sleep.get_sleep_document(document_id=document_id) class TestSession(unittest.TestCase): @@ -870,7 +875,7 @@ def test_get_session_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_session_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -901,13 +906,13 @@ def test_get_session_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/session", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_session_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.session.get_session_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -949,14 +954,14 @@ def test_get_session_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/session/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_session_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_session_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.session.get_session_document(document_id=document_id) class TestTag(unittest.TestCase): @@ -1010,7 +1015,7 @@ def test_get_tag_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_tag_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -1040,13 +1045,13 @@ def test_get_tag_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/tag", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_tag_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.tag.get_tag_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -1081,14 +1086,14 @@ def test_get_tag_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/tag/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_tag_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_tag_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.tag.get_tag_document(document_id=document_id) class TestWorkout(unittest.TestCase): @@ -1150,7 +1155,7 @@ def test_get_workout_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_workout_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -1183,13 +1188,13 @@ def test_get_workout_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/workout", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_workout_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.workout.get_workout_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -1233,14 +1238,14 @@ def test_get_workout_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/workout/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_workout_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_workout_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.workout.get_workout_document(document_id=document_id) class TestEnhancedTag(unittest.TestCase): @@ -1309,7 +1314,7 @@ def test_get_enhanced_tag_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_enhanced_tag_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -1339,13 +1344,13 @@ def test_get_enhanced_tag_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/enhanced_tag", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_enhanced_tag_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.enhanced_tag.get_enhanced_tag_documents( start_date="2024-03-01", end_date="2024-03-31" ) @@ -1387,14 +1392,14 @@ def test_get_enhanced_tag_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/enhanced_tag/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_enhanced_tag_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_enhanced_tag_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.enhanced_tag.get_enhanced_tag_document(document_id=document_id) class TestDailySpo2(unittest.TestCase): @@ -1459,7 +1464,7 @@ def test_get_daily_spo2_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_spo2_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -1489,13 +1494,13 @@ def test_get_daily_spo2_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/daily_spo2", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_daily_spo2_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.daily_spo2.get_daily_spo2_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -1533,14 +1538,14 @@ def test_get_daily_spo2_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/daily_spo2/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_daily_spo2_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_spo2_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.daily_spo2.get_daily_spo2_document(document_id=document_id) class TestSleepTime(unittest.TestCase): @@ -1627,7 +1632,7 @@ def test_get_sleep_time_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_sleep_time_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -1657,13 +1662,13 @@ def test_get_sleep_time_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/sleep_time", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_sleep_time_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.sleep_time.get_sleep_time_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -1716,7 +1721,7 @@ def test_get_sleep_time_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/sleep_time/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") @@ -1724,9 +1729,9 @@ def test_get_sleep_time_document_error(self, mock_get): # As per the implementation note, this endpoint might not exist. # If it doesn't, the API would return a 404, which _make_request would # raise as an HTTPError (a subclass of RequestException). - mock_get.side_effect = RequestException("API error or Not Found") + mock_get.side_effect = requests.exceptions.ConnectionError("API error or Not Found") document_id = "test_st_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.sleep_time.get_sleep_time_document(document_id=document_id) class TestRestModePeriod(unittest.TestCase): @@ -1790,7 +1795,7 @@ def test_get_rest_mode_period_documents(self, mock_get): "end_date": end_date_str, "next_token": "test_rmp_token", }, - + timeout=30.0, ) @patch("requests.get") @@ -1820,13 +1825,13 @@ def test_get_rest_mode_period_documents_with_string_dates(self, mock_get): f"{self.client.BASE_URL}/usercollection/rest_mode_period", headers=self.client.headers, params={"start_date": start_date_str, "end_date": end_date_str}, - + timeout=30.0, ) @patch("requests.get") def test_get_rest_mode_period_documents_error(self, mock_get): - mock_get.side_effect = RequestException("API error") - with self.assertRaises(RequestException): + mock_get.side_effect = OuraConnectionError("API error") + with self.assertRaises(OuraConnectionError): self.client.rest_mode_period.get_rest_mode_period_documents( start_date="2024-03-10", end_date="2024-03-11" ) @@ -1868,14 +1873,14 @@ def test_get_rest_mode_period_document(self, mock_get): f"{self.client.BASE_URL}/usercollection/rest_mode_period/{document_id}", headers=self.client.headers, params=None, - + timeout=30.0, ) @patch("requests.get") def test_get_rest_mode_period_document_error(self, mock_get): - mock_get.side_effect = RequestException("API error") + mock_get.side_effect = OuraConnectionError("API error") document_id = "test_rmp_single_error" - with self.assertRaises(RequestException): + with self.assertRaises(OuraConnectionError): self.client.rest_mode_period.get_rest_mode_period_document( document_id=document_id ) @@ -1900,6 +1905,7 @@ def test_get_daily_stress_documents_no_params(self, mock_get): f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, params={}, + timeout=30.0, ) @patch("requests.get") @@ -1917,6 +1923,7 @@ def test_get_daily_stress_documents_start_date(self, mock_get): f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, params={"start_date": "2024-01-01"}, + timeout=30.0, ) @patch("requests.get") @@ -1932,6 +1939,7 @@ def test_get_daily_stress_documents_end_date(self, mock_get): f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, params={"end_date": "2024-01-31"}, + timeout=30.0, ) @patch("requests.get") @@ -1949,6 +1957,7 @@ def test_get_daily_stress_documents_start_and_end_date(self, mock_get): f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, params={"start_date": "2024-01-01", "end_date": "2024-01-31"}, + timeout=30.0, ) @patch("requests.get") @@ -1966,6 +1975,7 @@ def test_get_daily_stress_documents_next_token(self, mock_get): f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, params={"next_token": "some_token"}, + timeout=30.0, ) @patch("requests.get") @@ -2004,36 +2014,33 @@ def test_get_daily_stress_documents_success(self, mock_get): f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, params={"start_date": "2024-03-15"}, + timeout=30.0, ) @patch("requests.get") def test_get_daily_stress_documents_api_error_400(self, mock_get): + # Mock a 400 error response mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("400 Client Error") - ) - mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): - self.client.daily_stress.get_daily_stress_documents() - - @patch("requests.get") - def test_get_daily_stress_documents_api_error_401(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("401 Client Error") - ) + mock_response.ok = False + mock_response.status_code = 400 + mock_response.reason = "Client Error" + mock_response.json.return_value = {"error": "400 Client Error"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + + with self.assertRaises(OuraClientError): self.client.daily_stress.get_daily_stress_documents() @patch("requests.get") def test_get_daily_stress_documents_api_error_429(self, mock_get): + # Mock a 429 error response mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("429 Client Error") - ) + mock_response.ok = False + mock_response.status_code = 429 + mock_response.reason = "Too Many Requests" + mock_response.json.return_value = {"error": "429 Client Error"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + + with self.assertRaises(OuraRateLimitError): self.client.daily_stress.get_daily_stress_documents() @patch("requests.get") @@ -2064,18 +2071,21 @@ def test_get_daily_stress_document_success(self, mock_get): f"{self.base_url}/usercollection/daily_stress/{document_id}", headers=self.client.headers, params=None, # No params for single document GET + timeout=30.0, ) @patch("requests.get") def test_get_daily_stress_document_not_found_404(self, mock_get): document_id = "non_existent_id" + # Mock a 404 error response mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("404 Client Error: Not Found") - ) + mock_response.ok = False + mock_response.status_code = 404 + mock_response.reason = "Not Found" + mock_response.json.return_value = {"error": "404 Client Error: Not Found"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + with self.assertRaises(OuraNotFoundError): self.client.daily_stress.get_daily_stress_document(document_id) class TestDailyResilience(unittest.TestCase): @@ -2098,6 +2108,7 @@ def test_get_daily_resilience_documents_no_params(self, mock_get): f"{self.base_url}/usercollection/daily_resilience", headers=self.client.headers, params={}, + timeout=30.0, ) @patch("requests.get") @@ -2115,6 +2126,7 @@ def test_get_daily_resilience_documents_start_date(self, mock_get): f"{self.base_url}/usercollection/daily_resilience", headers=self.client.headers, params={"start_date": "2024-02-01"}, + timeout=30.0, ) @patch("requests.get") @@ -2132,6 +2144,7 @@ def test_get_daily_resilience_documents_end_date(self, mock_get): f"{self.base_url}/usercollection/daily_resilience", headers=self.client.headers, params={"end_date": "2024-02-28"}, + timeout=30.0, ) @patch("requests.get") @@ -2149,6 +2162,7 @@ def test_get_daily_resilience_documents_start_and_end_date(self, mock_get): f"{self.base_url}/usercollection/daily_resilience", headers=self.client.headers, params={"start_date": "2024-02-01", "end_date": "2024-02-28"}, + timeout=30.0, ) @patch("requests.get") @@ -2166,6 +2180,7 @@ def test_get_daily_resilience_documents_next_token(self, mock_get): f"{self.base_url}/usercollection/daily_resilience", headers=self.client.headers, params={"next_token": "res_token"}, + timeout=30.0, ) @patch("requests.get") @@ -2210,36 +2225,18 @@ def test_get_daily_resilience_documents_success(self, mock_get): f"{self.base_url}/usercollection/daily_resilience", headers=self.client.headers, params={"start_date": "2024-03-18"}, + timeout=30.0, ) @patch("requests.get") def test_get_daily_resilience_documents_api_error_400(self, mock_get): mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("400 Client Error") - ) + mock_response.ok = False + mock_response.status_code = 400 + mock_response.reason = "Bad Request" + mock_response.json.return_value = {"error": "400 Client Error"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): - self.client.daily_resilience.get_daily_resilience_documents() - - @patch("requests.get") - def test_get_daily_resilience_documents_api_error_401(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("401 Client Error") - ) - mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): - self.client.daily_resilience.get_daily_resilience_documents() - - @patch("requests.get") - def test_get_daily_resilience_documents_api_error_429(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("429 Client Error") - ) - mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + with self.assertRaises(OuraClientError): self.client.daily_resilience.get_daily_resilience_documents() @patch("requests.get") @@ -2277,18 +2274,20 @@ def test_get_daily_resilience_document_success(self, mock_get): f"{self.base_url}/usercollection/daily_resilience/{document_id}", headers=self.client.headers, params=None, + timeout=30.0, ) @patch("requests.get") def test_get_daily_resilience_document_not_found_404(self, mock_get): document_id = "non_existent_res_id" mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("404 Client Error: Not Found") - ) + mock_response.ok = False + mock_response.status_code = 404 + mock_response.reason = "Not Found" + mock_response.json.return_value = {"error": "404 Client Error: Not Found"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + with self.assertRaises(OuraNotFoundError): self.client.daily_resilience.get_daily_resilience_document( document_id ) @@ -2315,6 +2314,7 @@ def test_get_daily_cardiovascular_age_documents_no_params(self, mock_get): f"{self.base_url}/usercollection/daily_cardiovascular_age", headers=self.client.headers, params={}, + timeout=30.0, ) @patch("requests.get") @@ -2332,6 +2332,7 @@ def test_get_daily_cardiovascular_age_documents_start_date(self, mock_get): f"{self.base_url}/usercollection/daily_cardiovascular_age", headers=self.client.headers, params={"start_date": "2024-03-01"}, + timeout=30.0, ) @patch("requests.get") @@ -2349,6 +2350,7 @@ def test_get_daily_cardiovascular_age_documents_end_date(self, mock_get): f"{self.base_url}/usercollection/daily_cardiovascular_age", headers=self.client.headers, params={"end_date": "2024-03-31"}, + timeout=30.0, ) @patch("requests.get") @@ -2366,6 +2368,7 @@ def test_get_daily_cardiovascular_age_documents_start_and_end_date(self, mock_ge f"{self.base_url}/usercollection/daily_cardiovascular_age", headers=self.client.headers, params={"start_date": "2024-03-01", "end_date": "2024-03-31"}, + timeout=30.0, ) @patch("requests.get") @@ -2383,6 +2386,7 @@ def test_get_daily_cardiovascular_age_documents_next_token(self, mock_get): f"{self.base_url}/usercollection/daily_cardiovascular_age", headers=self.client.headers, params={"next_token": "cva_token"}, + timeout=30.0, ) @patch("requests.get") @@ -2422,36 +2426,18 @@ def test_get_daily_cardiovascular_age_documents_success(self, mock_get): f"{self.base_url}/usercollection/daily_cardiovascular_age", headers=self.client.headers, params={"start_date": "2024-03-20"}, + timeout=30.0, ) @patch("requests.get") def test_get_daily_cardiovascular_age_documents_api_error_400(self, mock_get): mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("400 Client Error") - ) - mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): - self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents() - - @patch("requests.get") - def test_get_daily_cardiovascular_age_documents_api_error_401(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("401 Client Error") - ) - mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): - self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents() - - @patch("requests.get") - def test_get_daily_cardiovascular_age_documents_api_error_429(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("429 Client Error") - ) + mock_response.ok = False + mock_response.status_code = 400 + mock_response.reason = "Bad Request" + mock_response.json.return_value = {"error": "400 Client Error"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + with self.assertRaises(OuraClientError): self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents() @patch("requests.get") @@ -2481,18 +2467,20 @@ def test_get_daily_cardiovascular_age_document_success(self, mock_get): f"{self.base_url}/usercollection/daily_cardiovascular_age/{document_id}", headers=self.client.headers, params=None, + timeout=30.0, ) @patch("requests.get") def test_get_daily_cardiovascular_age_document_not_found_404(self, mock_get): document_id = "non_existent_cva_id" mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("404 Client Error: Not Found") - ) + mock_response.ok = False + mock_response.status_code = 404 + mock_response.reason = "Not Found" + mock_response.json.return_value = {"error": "404 Client Error: Not Found"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + with self.assertRaises(OuraNotFoundError): self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_document( document_id ) @@ -2518,6 +2506,7 @@ def test_get_vo2_max_documents_no_params(self, mock_get): f"{self.base_url}{self.correct_path_segment}", headers=self.client.headers, params={}, + timeout=30.0, ) @patch("requests.get") @@ -2533,6 +2522,7 @@ def test_get_vo2_max_documents_start_date(self, mock_get): f"{self.base_url}{self.correct_path_segment}", headers=self.client.headers, params={"start_date": "2024-04-01"}, + timeout=30.0, ) @patch("requests.get") @@ -2548,6 +2538,7 @@ def test_get_vo2_max_documents_end_date(self, mock_get): f"{self.base_url}{self.correct_path_segment}", headers=self.client.headers, params={"end_date": "2024-04-30"}, + timeout=30.0, ) @patch("requests.get") @@ -2565,6 +2556,7 @@ def test_get_vo2_max_documents_start_and_end_date(self, mock_get): f"{self.base_url}{self.correct_path_segment}", headers=self.client.headers, params={"start_date": "2024-04-01", "end_date": "2024-04-30"}, + timeout=30.0, ) @patch("requests.get") @@ -2580,6 +2572,7 @@ def test_get_vo2_max_documents_next_token(self, mock_get): f"{self.base_url}{self.correct_path_segment}", headers=self.client.headers, params={"next_token": "vo2_token"}, + timeout=30.0, ) @patch("requests.get") @@ -2616,36 +2609,18 @@ def test_get_vo2_max_documents_success(self, mock_get): f"{self.base_url}{self.correct_path_segment}", headers=self.client.headers, params={"start_date": "2024-04-10"}, + timeout=30.0, ) @patch("requests.get") def test_get_vo2_max_documents_api_error_400(self, mock_get): mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("400 Client Error") - ) + mock_response.ok = False + mock_response.status_code = 400 + mock_response.reason = "Bad Request" + mock_response.json.return_value = {"error": "400 Client Error"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): - self.client.vo2_max.get_vo2_max_documents() - - @patch("requests.get") - def test_get_vo2_max_documents_api_error_401(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("401 Client Error") - ) - mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): - self.client.vo2_max.get_vo2_max_documents() - - @patch("requests.get") - def test_get_vo2_max_documents_api_error_429(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("429 Client Error") - ) - mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + with self.assertRaises(OuraClientError): self.client.vo2_max.get_vo2_max_documents() @patch("requests.get") @@ -2676,16 +2651,18 @@ def test_get_vo2_max_document_success(self, mock_get): f"{self.base_url}{self.correct_path_segment}/{document_id}", headers=self.client.headers, params=None, + timeout=30.0, ) @patch("requests.get") def test_get_vo2_max_document_not_found_404(self, mock_get): document_id = "non_existent_vo2_id" mock_response = MagicMock() - mock_response.raise_for_status.side_effect = ( - requests.exceptions.HTTPError("404 Client Error: Not Found") - ) + mock_response.ok = False + mock_response.status_code = 404 + mock_response.reason = "Not Found" + mock_response.json.return_value = {"error": "404 Client Error: Not Found"} mock_get.return_value = mock_response - with self.assertRaises(requests.exceptions.HTTPError): + with self.assertRaises(OuraNotFoundError): self.client.vo2_max.get_vo2_max_document(document_id) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..947a6d5 --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,357 @@ +"""Tests for error handling and retry logic.""" + +import unittest +from unittest.mock import patch, MagicMock +import requests +from oura_api_client.api.client import OuraClient +from oura_api_client.exceptions import ( + OuraAPIError, + OuraAuthenticationError, + OuraAuthorizationError, + OuraNotFoundError, + OuraRateLimitError, + OuraServerError, + OuraClientError, + OuraConnectionError, + OuraTimeoutError, + create_api_error +) +from oura_api_client.utils import RetryConfig, exponential_backoff, should_retry + + +class TestExceptions(unittest.TestCase): + """Test custom exception classes.""" + + def test_oura_api_error(self): + """Test OuraAPIError base class.""" + error = OuraAPIError("Test error", status_code=400, endpoint="/test") + self.assertEqual(str(error), "Test error | Status: 400 | Endpoint: /test") + self.assertEqual(error.message, "Test error") + self.assertEqual(error.status_code, 400) + self.assertEqual(error.endpoint, "/test") + + def test_oura_rate_limit_error(self): + """Test OuraRateLimitError with retry_after.""" + error = OuraRateLimitError( + "Rate limit exceeded", + status_code=429, + endpoint="/test", + retry_after=60 + ) + self.assertEqual(error.retry_after, 60) + self.assertEqual(error.status_code, 429) + + def test_create_api_error_with_json_response(self): + """Test create_api_error with JSON error response.""" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.reason = "Unauthorized" + mock_response.json.return_value = {"error": "Invalid token"} + + error = create_api_error(mock_response, "/test") + self.assertIsInstance(error, OuraAuthenticationError) + self.assertEqual(error.message, "Invalid token") + self.assertEqual(error.status_code, 401) + self.assertEqual(error.endpoint, "/test") + + def test_create_api_error_without_json(self): + """Test create_api_error when response has no JSON.""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.reason = "Internal Server Error" + mock_response.json.side_effect = ValueError("No JSON") + + error = create_api_error(mock_response, "/test") + self.assertIsInstance(error, OuraServerError) + self.assertEqual(error.message, "HTTP 500: Internal Server Error") + + def test_create_api_error_with_retry_after(self): + """Test create_api_error for rate limit with Retry-After header.""" + mock_response = MagicMock() + mock_response.status_code = 429 + mock_response.reason = "Too Many Requests" + mock_response.headers = {"Retry-After": "120"} + mock_response.json.return_value = {"error": "Rate limit exceeded"} + + error = create_api_error(mock_response, "/test") + self.assertIsInstance(error, OuraRateLimitError) + self.assertEqual(error.retry_after, 120) + + def test_create_api_error_status_mapping(self): + """Test that create_api_error returns correct exception types.""" + test_cases = [ + (401, OuraAuthenticationError), + (403, OuraAuthorizationError), + (404, OuraNotFoundError), + (429, OuraRateLimitError), + (400, OuraClientError), + (422, OuraClientError), + (500, OuraServerError), + (502, OuraServerError), + (503, OuraServerError), + ] + + for status_code, expected_type in test_cases: + mock_response = MagicMock() + mock_response.status_code = status_code + mock_response.reason = f"Status {status_code}" + mock_response.json.side_effect = ValueError() + mock_response.headers = {} + + error = create_api_error(mock_response) + self.assertIsInstance(error, expected_type) + + +class TestRetryLogic(unittest.TestCase): + """Test retry utilities.""" + + def test_exponential_backoff(self): + """Test exponential backoff calculation.""" + # Test without jitter + self.assertEqual(exponential_backoff(0, base_delay=1.0, jitter=False), 1.0) + self.assertEqual(exponential_backoff(1, base_delay=1.0, jitter=False), 2.0) + self.assertEqual(exponential_backoff(2, base_delay=1.0, jitter=False), 4.0) + self.assertEqual(exponential_backoff(3, base_delay=1.0, jitter=False), 8.0) + + # Test max delay + self.assertEqual( + exponential_backoff(10, base_delay=1.0, max_delay=5.0, jitter=False), + 5.0 + ) + + # Test with jitter (should be within ±25% of base value) + for attempt in range(5): + delay = exponential_backoff(attempt, base_delay=1.0, jitter=True) + expected = 1.0 * (2 ** attempt) + self.assertGreaterEqual(delay, expected * 0.75) + self.assertLessEqual(delay, expected * 1.25) + + def test_should_retry(self): + """Test should_retry logic.""" + # Test max retries exceeded + error = OuraServerError("Server error") + self.assertFalse(should_retry(error, attempt=3, max_retries=3)) + self.assertTrue(should_retry(error, attempt=2, max_retries=3)) + + # Test retryable errors + retryable_errors = [ + OuraServerError("Server error"), + OuraConnectionError("Connection failed"), + OuraTimeoutError("Request timed out"), + OuraRateLimitError("Rate limited", retry_after=60), + ] + + for error in retryable_errors: + self.assertTrue(should_retry(error, attempt=0, max_retries=3)) + + # Test non-retryable errors + non_retryable_errors = [ + OuraAuthenticationError("Invalid token"), + OuraAuthorizationError("Forbidden"), + OuraNotFoundError("Not found"), + OuraClientError("Bad request"), + ] + + for error in non_retryable_errors: + self.assertFalse(should_retry(error, attempt=0, max_retries=3)) + + # Test rate limit with large retry_after + error = OuraRateLimitError("Rate limited", retry_after=600) + self.assertFalse(should_retry(error, attempt=0, max_retries=3)) + + +class TestOuraClientErrorHandling(unittest.TestCase): + """Test OuraClient error handling.""" + + def setUp(self): + """Set up test client.""" + self.client = OuraClient("test_token") + + @patch("requests.get") + def test_make_request_success(self, mock_get): + """Test successful request.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"data": "test"} + mock_get.return_value = mock_response + + result = self.client._make_request("/test") + self.assertEqual(result, {"data": "test"}) + mock_get.assert_called_once() + + @patch("requests.get") + def test_make_request_http_error(self, mock_get): + """Test request with HTTP error.""" + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 404 + mock_response.reason = "Not Found" + mock_response.json.return_value = {"error": "Resource not found"} + mock_get.return_value = mock_response + + with self.assertRaises(OuraNotFoundError) as cm: + self.client._make_request("/test") + + self.assertEqual(cm.exception.message, "Resource not found") + self.assertEqual(cm.exception.status_code, 404) + self.assertEqual(cm.exception.endpoint, "/test") + + @patch("requests.get") + def test_make_request_timeout(self, mock_get): + """Test request timeout.""" + mock_get.side_effect = requests.exceptions.Timeout("Timeout") + + with self.assertRaises(OuraTimeoutError) as cm: + self.client._make_request("/test") + + self.assertIn("timed out", cm.exception.message) + self.assertEqual(cm.exception.endpoint, "/test") + + @patch("requests.get") + def test_make_request_connection_error(self, mock_get): + """Test connection error.""" + mock_get.side_effect = requests.exceptions.ConnectionError("Connection failed") + + with self.assertRaises(OuraConnectionError) as cm: + self.client._make_request("/test") + + self.assertIn("Failed to connect", cm.exception.message) + self.assertEqual(cm.exception.endpoint, "/test") + + @patch("requests.get") + def test_make_request_with_retry_success(self, mock_get): + """Test successful retry after transient error.""" + # First call fails with server error, second succeeds + mock_response_error = MagicMock() + mock_response_error.ok = False + mock_response_error.status_code = 500 + mock_response_error.reason = "Internal Server Error" + mock_response_error.json.return_value = {} + + mock_response_success = MagicMock() + mock_response_success.ok = True + mock_response_success.json.return_value = {"data": "success"} + + mock_get.side_effect = [mock_response_error, mock_response_success] + + # Enable retry + self.client.retry_config = RetryConfig( + max_retries=3, + base_delay=0.01, # Short delay for testing + jitter=False + ) + + result = self.client._make_request("/test") + self.assertEqual(result, {"data": "success"}) + self.assertEqual(mock_get.call_count, 2) + + @patch("requests.get") + @patch("time.sleep") + def test_make_request_with_retry_exhausted(self, mock_sleep, mock_get): + """Test retry exhaustion.""" + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 500 + mock_response.reason = "Internal Server Error" + mock_response.json.return_value = {"error": "Server error"} + mock_get.return_value = mock_response + + # Enable retry with limited attempts + self.client.retry_config = RetryConfig( + max_retries=2, + base_delay=0.01, + jitter=False + ) + + with self.assertRaises(OuraServerError) as cm: + self.client._make_request("/test") + + self.assertEqual(cm.exception.message, "Server error") + self.assertEqual(mock_get.call_count, 3) # Initial + 2 retries + + @patch("requests.get") + @patch("time.sleep") + def test_make_request_with_rate_limit_retry(self, mock_sleep, mock_get): + """Test retry with rate limit and Retry-After header.""" + mock_response_rate_limit = MagicMock() + mock_response_rate_limit.ok = False + mock_response_rate_limit.status_code = 429 + mock_response_rate_limit.reason = "Too Many Requests" + mock_response_rate_limit.headers = {"Retry-After": "2"} + mock_response_rate_limit.json.return_value = {"error": "Rate limited"} + + mock_response_success = MagicMock() + mock_response_success.ok = True + mock_response_success.json.return_value = {"data": "success"} + + mock_get.side_effect = [mock_response_rate_limit, mock_response_success] + + self.client.retry_config = RetryConfig(max_retries=3) + + result = self.client._make_request("/test") + self.assertEqual(result, {"data": "success"}) + + # Should have slept for the Retry-After duration + mock_sleep.assert_called_once_with(2) + + @patch("requests.get") + def test_make_request_no_retry_on_client_error(self, mock_get): + """Test that client errors are not retried.""" + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 400 + mock_response.reason = "Bad Request" + mock_response.json.return_value = {"error": "Invalid parameters"} + mock_get.return_value = mock_response + + self.client.retry_config = RetryConfig(max_retries=3) + + with self.assertRaises(OuraClientError) as cm: + self.client._make_request("/test") + + self.assertEqual(cm.exception.message, "Invalid parameters") + self.assertEqual(mock_get.call_count, 1) # No retries + + def test_make_request_endpoint_normalization(self): + """Test endpoint normalization.""" + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {} + mock_get.return_value = mock_response + + # Test various endpoint formats + test_cases = [ + ("/test", "https://api.ouraring.com/v2/test"), + ("test", "https://api.ouraring.com/v2/test"), + ("/v2/test", "https://api.ouraring.com/v2/test"), + ("v2/test", "https://api.ouraring.com/v2/test"), + ] + + for endpoint, expected_url in test_cases: + mock_get.reset_mock() + self.client._make_request(endpoint) + actual_url = mock_get.call_args[0][0] + self.assertEqual(actual_url, expected_url) + + def test_retry_config_disabled(self): + """Test that retry can be disabled.""" + self.client.retry_config = RetryConfig(enabled=False) + + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 500 + mock_response.reason = "Server Error" + mock_response.json.return_value = {} + mock_get.return_value = mock_response + + with self.assertRaises(OuraServerError): + self.client._make_request("/test") + + # Should not retry when disabled + self.assertEqual(mock_get.call_count, 1) + + +if __name__ == "__main__": + unittest.main()