Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/guides/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ OtfError
│ └── RetryableOtfRequestError
├── OtfAuthenticationError
├── OtfTransportError
├── OtfConfigurationError
├── BookingError
│ ├── AlreadyBookedError
│ ├── ConflictingBookingError
Expand All @@ -30,6 +31,7 @@ from otf_api.exceptions import (
RetryableOtfRequestError,
OtfAuthenticationError,
OtfTransportError,
OtfConfigurationError,
BookingError,
AlreadyBookedError,
ConflictingBookingError,
Expand Down Expand Up @@ -112,6 +114,21 @@ except OtfTransportError as e:
# The original httpx exception is available as e.__cause__
```

### OtfConfigurationError

Raised when the underlying boto3/botocore client is misconfigured or invoked with invalid parameters — for example, an invalid `AWS_PROFILE` or a parameter validation failure. Unlike `OtfTransportError`, these failures are not retryable: retrying won't fix a bad configuration. Wraps the underlying `botocore` error so consumers don't need to import `botocore`.

```python
from otf_api import OtfUser
from otf_api.exceptions import OtfConfigurationError

try:
user = OtfUser(username="user@example.com", password="correct")
except OtfConfigurationError as e:
print(f"Configuration error: {e}")
# The original botocore.exceptions.BotoCoreError is available as e.__cause__
```

### BookingError

Base class for all booking-related errors. Carries identifiers for the affected booking.
Expand Down
47 changes: 43 additions & 4 deletions src/otf_api/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from functools import cached_property
from logging import getLogger
from time import sleep
from typing import Any, ClassVar
from typing import Any, ClassVar, NoReturn

import httpx
import jwt
Expand All @@ -16,12 +16,18 @@
from botocore.awsrequest import AWSRequest
from botocore.config import Config
from botocore.credentials import Credentials
from botocore.exceptions import ClientError
from botocore.exceptions import BotoCoreError, ClientError, HTTPClientError
from botocore.exceptions import ConnectionError as BotoConnectionError
from pycognito import AWSSRP, Cognito
from pycognito.aws_srp import generate_hash_device

from otf_api.cache import get_cache
from otf_api.exceptions import NoCredentialsError, OtfAuthenticationError
from otf_api.exceptions import (
NoCredentialsError,
OtfAuthenticationError,
OtfConfigurationError,
OtfTransportError,
)

if typing.TYPE_CHECKING:
from mypy_boto3_cognito_identity import CognitoIdentityClient
Expand All @@ -40,6 +46,33 @@
CACHE = get_cache()


def raise_for_botocore_error(e: BotoCoreError, action: str) -> NoReturn:
"""Log and re-raise a caught BotoCoreError as the correct public Otf* exception.

ClientError is a sibling of BotoCoreError, not a subclass, so this is never called for
API-level failures. ConnectionError and HTTPClientError are the two transport-failure
branches of BotoCoreError's hierarchy (connectivity, timeout, failure to reach an
already-resolved endpoint) — genuinely retryable, so they're reported as
OtfTransportError. Everything else under BotoCoreError (ParamValidationError,
ProfileNotFound, NoRegionError, etc.) is a configuration/validation failure that a
retry cannot fix, so it's reported as OtfConfigurationError.

Args:
e: The caught BotoCoreError.
action: Present-tense description of what was happening, for the log message
(e.g. "authenticating with Cognito").

Raises:
OtfTransportError: If e is a connectivity, timeout, or endpoint-connection failure.
OtfConfigurationError: If e is any other BotoCoreError.
"""
if isinstance(e, BotoConnectionError | HTTPClientError):
LOGGER.exception("Transport error while %s", action)
raise OtfTransportError("OTF transport error") from e
LOGGER.exception("Configuration error while %s", action)
raise OtfConfigurationError("OTF configuration error") from e


class OtfCognito(Cognito):
"""A subclass of the pycognito Cognito class that adds the device_key to the auth_params.

Expand Down Expand Up @@ -319,6 +352,9 @@ def check_token(self, renew: bool = True) -> bool:
Raises:
AttributeError: If access_token is not set
NoCredentialsError: If refresh token has expired
OtfAuthenticationError: If token refresh fails for another Cognito-reported reason
OtfTransportError: If a connectivity, timeout, or endpoint failure occurs during refresh
OtfConfigurationError: If a boto3/botocore configuration or validation failure occurs during refresh

Returns:
bool: True if the access_token has expired, False otherwise
Expand All @@ -330,7 +366,10 @@ def check_token(self, renew: bool = True) -> bool:
LOGGER.warning("Tokens expired, attempting to login with username and password")
CACHE.clear()
raise NoCredentialsError("Cached tokens expired, please login again") from e
raise OtfAuthenticationError(str(e)) from e
LOGGER.exception("Failed to refresh Cognito tokens")
raise OtfAuthenticationError("OTF authentication failed") from e
except BotoCoreError as e:
raise_for_botocore_error(e, "refreshing Cognito tokens")

def renew_access_token(self) -> None:
"""Sets a new access token on the User using the cached refresh token and device metadata.
Expand Down
8 changes: 5 additions & 3 deletions src/otf_api/auth/user.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from logging import getLogger

import attrs
from botocore.exceptions import ClientError
from botocore.exceptions import BotoCoreError, ClientError

from otf_api.auth.auth import HttpxCognitoAuth, OtfCognito
from otf_api.auth.auth import HttpxCognitoAuth, OtfCognito, raise_for_botocore_error
from otf_api.auth.utils import get_username_password
from otf_api.exceptions import NoCredentialsError, OtfAuthenticationError

Expand All @@ -27,7 +27,9 @@ def _create_cognito(username: str | None, password: str | None, **kwargs: str |
raise
except ClientError as e:
_log_initial_auth_error(e, username)
raise OtfAuthenticationError(str(e)) from e
raise OtfAuthenticationError("OTF authentication failed") from e
except BotoCoreError as e:
Comment thread
NodeJSmith marked this conversation as resolved.
raise_for_botocore_error(e, "authenticating with Cognito")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the transport-error contract for Cognito failures

When a botocore connectivity failure occurs during OtfUser construction, this new branch raises OtfTransportError immediately with a botocore exception as its cause. However, docs/guides/error-handling.md still states that transport errors are automatically retried and always wrap httpx; neither is true here because the retry decorator only covers OtfClient.do(), after user construction. Update the public reference to describe the botocore cause and qualify which transport errors are retried.

Useful? React with 👍 / 👎.

except Exception:
LOGGER.exception("Failed to authenticate with Cognito")
raise
Expand Down
10 changes: 10 additions & 0 deletions src/otf_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"ConflictingBookingError",
"NoCredentialsError",
"OtfAuthenticationError",
"OtfConfigurationError",
"OtfError",
"OtfRequestError",
"OtfTransportError",
Expand Down Expand Up @@ -100,5 +101,14 @@ class OtfTransportError(OtfError):
"""


class OtfConfigurationError(OtfError):
Comment thread
NodeJSmith marked this conversation as resolved.
"""Raised when the boto3/botocore client is misconfigured or invoked with invalid parameters.

Covers non-network BotoCoreError failures (e.g. a missing AWS profile, an unresolvable region,
or a parameter validation error) that a retry cannot fix. The original error is available via
``__cause__``.
"""


class NoCredentialsError(OtfError):
"""Raised when no credentials are provided and no cached tokens are available."""
48 changes: 46 additions & 2 deletions tests/test_api/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
import httpx
import pytest
import respx
from botocore.exceptions import ClientError
from botocore.exceptions import ClientError, EndpointConnectionError, ParamValidationError

from otf_api.api.client import API_BASE_URL, OtfClient
from otf_api.auth.user import OtfUser
from otf_api.exceptions import NoCredentialsError, OtfAuthenticationError, OtfError, OtfTransportError
from otf_api.exceptions import (
NoCredentialsError,
OtfAuthenticationError,
OtfConfigurationError,
OtfError,
OtfTransportError,
)


def _make_client_error(code: str = "NotAuthorizedException", message: str = "bad creds") -> ClientError:
Expand Down Expand Up @@ -51,6 +57,17 @@ def cognito_side_effect(**kwargs: object) -> Never:

assert exc_info.value.__cause__ is error

def test_auth_error_message_is_fixed_and_safe(self):
error = _make_client_error(message="secret provider detail")
with (
patch("otf_api.auth.user.OtfCognito", side_effect=error),
pytest.raises(OtfAuthenticationError) as exc_info,
):
OtfUser(username="test@example.com", password="wrong")

assert str(exc_info.value) == "OTF authentication failed"
assert "secret provider detail" not in str(exc_info.value)

def test_auth_error_is_subclass_of_otf_error(self):
assert issubclass(OtfAuthenticationError, OtfError)

Expand Down Expand Up @@ -94,3 +111,30 @@ def test_read_error_raises_transport_error(self, mock_user):

def test_transport_error_is_subclass_of_otf_error(self):
assert issubclass(OtfTransportError, OtfError)

def test_construction_time_connectivity_failure_raises_transport_error(self):
error = EndpointConnectionError(endpoint_url="https://cognito-idp.example.com")
with (
patch("otf_api.auth.user.OtfCognito", side_effect=error),
pytest.raises(OtfTransportError) as exc_info,
):
OtfUser(username="test@example.com", password="wrong")

assert str(exc_info.value) == "OTF transport error"
assert exc_info.value.__cause__ is error


class TestOtfConfigurationError:
def test_construction_time_config_failure_raises_configuration_error(self):
error = ParamValidationError(report="bad params")
with (
patch("otf_api.auth.user.OtfCognito", side_effect=error),
pytest.raises(OtfConfigurationError) as exc_info,
):
OtfUser(username="test@example.com", password="wrong")

assert str(exc_info.value) == "OTF configuration error"
assert exc_info.value.__cause__ is error

def test_configuration_error_is_subclass_of_otf_error(self):
assert issubclass(OtfConfigurationError, OtfError)
61 changes: 60 additions & 1 deletion tests/test_auth/test_otf_cognito.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from unittest.mock import MagicMock, PropertyMock, patch

import pytest
from botocore.exceptions import ClientError
from botocore.exceptions import ClientError, EndpointConnectionError, ParamValidationError

from otf_api.auth.auth import NoCredentialsError, OtfCognito
from otf_api.exceptions import OtfAuthenticationError, OtfConfigurationError, OtfTransportError

from .conftest import fake_tokens

Expand Down Expand Up @@ -231,6 +232,64 @@ def test_check_token_not_authorized(mock_cache, mock_verify_token):
mock_clear.assert_called_once()


def test_check_token_other_client_error_raises_auth_error(mock_cache, mock_verify_token):
"""check_token raises OtfAuthenticationError with a fixed, safe message for non-auth-expiry ClientErrors."""
mock_cache.write_token_data_to_cache({"access_token": _ACCESS_TOKEN, "id_token": "id", "refresh_token": "rt"})
mock_cache.write_device_data_to_cache({"device_key": "dk", "device_group_key": "dgk", "device_password": "dp"})

with patch("pycognito.Cognito.check_token", return_value=False):
cognito = OtfCognito(username="user@test.com")

error = _client_error("InternalErrorException", message="secret provider detail")
with (
patch("pycognito.Cognito.check_token", side_effect=error),
pytest.raises(OtfAuthenticationError) as exc_info,
):
cognito.check_token()

assert str(exc_info.value) == "OTF authentication failed"
assert "secret provider detail" not in str(exc_info.value)
assert exc_info.value.__cause__ is error


def test_check_token_connectivity_failure_raises_transport_error(mock_cache, mock_verify_token):
"""check_token raises OtfTransportError with a fixed, safe message on refresh-time connectivity failures."""
mock_cache.write_token_data_to_cache({"access_token": _ACCESS_TOKEN, "id_token": "id", "refresh_token": "rt"})
mock_cache.write_device_data_to_cache({"device_key": "dk", "device_group_key": "dgk", "device_password": "dp"})

with patch("pycognito.Cognito.check_token", return_value=False):
cognito = OtfCognito(username="user@test.com")

error = EndpointConnectionError(endpoint_url="https://cognito-idp.example.com")
with (
patch("pycognito.Cognito.check_token", side_effect=error),
pytest.raises(OtfTransportError) as exc_info,
):
cognito.check_token()

assert str(exc_info.value) == "OTF transport error"
assert exc_info.value.__cause__ is error


def test_check_token_configuration_error_raises_configuration_error(mock_cache, mock_verify_token):
"""check_token raises OtfConfigurationError with a fixed, safe message on non-transport BotoCoreErrors."""
mock_cache.write_token_data_to_cache({"access_token": _ACCESS_TOKEN, "id_token": "id", "refresh_token": "rt"})
mock_cache.write_device_data_to_cache({"device_key": "dk", "device_group_key": "dgk", "device_password": "dp"})

with patch("pycognito.Cognito.check_token", return_value=False):
cognito = OtfCognito(username="user@test.com")

error = ParamValidationError(report="bad params")
with (
patch("pycognito.Cognito.check_token", side_effect=error),
pytest.raises(OtfConfigurationError) as exc_info,
):
cognito.check_token()

assert str(exc_info.value) == "OTF configuration error"
assert exc_info.value.__cause__ is error


# ---------------------------------------------------------------------------
# Subtask 12 — _set_tokens happy path
# ---------------------------------------------------------------------------
Expand Down