diff --git a/tests/units/test_endpoint.py b/tests/units/test_endpoint.py new file mode 100644 index 00000000..6185931e --- /dev/null +++ b/tests/units/test_endpoint.py @@ -0,0 +1,179 @@ +"""Tests for the endpoint module.""" + +import pytest +from requests import Session, exceptions +from requests.models import Response as RequestResponse + +from tfe.endpoint import Endpoint +from tfe.exception import ( + TFEConnectionException, + TFEEndpointException, + TFETimeoutException, + TFEUnauthorizedException, +) + + +@pytest.fixture +def mock_session(mocker): + """Create a mock session for testing.""" + return mocker.Mock(spec=Session) + + +@pytest.fixture +def endpoint(mock_session): + """Create an Endpoint instance for testing.""" + return Endpoint(client=mock_session) + + +@pytest.fixture +def mock_response(mocker): + """Create a mock HTTP response.""" + response = mocker.Mock(spec=RequestResponse) + response.status_code = 200 + response.json.return_value = {"data": "test"} + return response + + +class TestEndpoint: + """Test cases for the Endpoint class.""" + + @pytest.mark.parametrize( + "method,expected_method,json_data,expected_call", + [ + ("GET", "get", None, ("get", "/test/path")), + ("POST", "post", {"key": "value"}, ("post", "/test/path")), + ("PUT", "put", {"key": "value"}, ("put", "/test/path")), + ("PATCH", "patch", {"key": "value"}, ("patch", "/test/path")), + ("DELETE", "delete", None, ("delete", "/test/path")), + ("get", "get", None, ("get", "/test/path")), # Test case insensitive + ( + "post", + "post", + {"data": "test"}, + ("post", "/test/path"), + ), # Test case insensitive + ], + ) + def test_make_request_all_methods( + self, endpoint, mock_response, method, expected_method, json_data, expected_call + ): + """Test _make_request with all supported HTTP methods.""" + # Setup the mock method to return our mock response + getattr(endpoint._http_client, expected_method).return_value = mock_response + + # Make the request + if json_data: + response = endpoint._make_request(method, "/test/path", json=json_data) + # Verify the correct method was called with the correct arguments + getattr(endpoint._http_client, expected_method).assert_called_once_with( + "/test/path", json=json_data + ) + else: + response = endpoint._make_request(method, "/test/path") + # Verify the correct method was called with the correct arguments + getattr(endpoint._http_client, expected_method).assert_called_once_with( + "/test/path" + ) + + # Verify the response is returned correctly + assert response == mock_response + + def test_make_request_unsupported_method(self, endpoint): + """Test that unsupported HTTP methods raise TFEEndpointException.""" + with pytest.raises( + TFEEndpointException, + match="Unexpected error occurred during INVALID request", + ): + endpoint._make_request("INVALID", "/test/path") + + +class TestEndpointErrorHandling: + """Test cases for endpoint error handling.""" + + @pytest.mark.parametrize( + "method,http_method,exception_class,request_exception,expected_message,expected_cause_type", + [ + ( + "GET", + "get", + TFEConnectionException, + exceptions.ConnectionError("Connection failed"), + "Failed to connect to TFE API", + exceptions.ConnectionError, + ), + ( + "POST", + "post", + TFETimeoutException, + exceptions.Timeout("Request timed out"), + "Request timed out", + exceptions.Timeout, + ), + ( + "PUT", + "put", + TFEEndpointException, + exceptions.RequestException("Request failed"), + "Request failed: Request failed", + exceptions.RequestException, + ), + ( + "DELETE", + "delete", + TFEEndpointException, + ValueError("Unexpected error"), + "Unexpected error occurred during DELETE request", + ValueError, + ), + ], + ) + def test_error_handling( + self, + endpoint, + method, + http_method, + exception_class, + request_exception, + expected_message, + expected_cause_type, + ): + """Test various error handling scenarios.""" + path = "/test/path" + getattr(endpoint._http_client, http_method).side_effect = request_exception + + with pytest.raises(exception_class) as exc_info: + endpoint._make_request(method, path) + + exception = exc_info.value + assert exception.message == expected_message + assert exception.method == method + assert exception.path == path + assert isinstance(exception.cause, expected_cause_type) + + def test_http_error_handling(self, endpoint, mocker): + """Test HTTP error handling (delegated to error_utils).""" + method, path = "GET", "/test/path" + + # Mock the handle_http_error function to raise an exception + mock_handle_http_error = mocker.patch("tfe.endpoint.handle_http_error") + mock_handle_http_error.side_effect = TFEUnauthorizedException( + message="Authentication failed", status_code=401, method=method, path=path + ) + + # Create mock response that raises HTTPError + mock_response = mocker.Mock() + mock_response.status_code = 401 + mock_response.json.return_value = {"error": "Unauthorized"} + mock_response.raise_for_status.side_effect = exceptions.HTTPError( + "401 Unauthorized" + ) + mock_response.raise_for_status.side_effect.response = mock_response + + endpoint._http_client.get.return_value = mock_response + + # This should call handle_http_error and raise TFEUnauthorizedException + with pytest.raises(TFEUnauthorizedException): + endpoint._make_request(method, path) + + # Verify that handle_http_error was called + mock_handle_http_error.assert_called_once() diff --git a/tests/units/test_error_utils.py b/tests/units/test_error_utils.py new file mode 100644 index 00000000..433dc60e --- /dev/null +++ b/tests/units/test_error_utils.py @@ -0,0 +1,293 @@ +"""Tests for the error_utils module.""" + +from unittest.mock import Mock + +import pytest +from requests import exceptions + +from tfe.error_utils import ( + extract_error_data, + handle_http_error, + parse_tfe_error_response, +) +from tfe.exception import ( + TFEEndpointException, + TFEForbiddenException, + TFENotFoundException, + TFEServerException, + TFEUnauthorizedException, + TFEValidationException, +) + + +class TestExtractErrorData: + """Test cases for extract_error_data function.""" + + def test_extract_none_response(self): + """Test handling None response.""" + result = extract_error_data(None) + assert result is None + + @pytest.mark.parametrize( + "json_return_value,json_side_effect,text_value,expected_result", + [ + # Test extracting JSON error data + ({"error": "test error"}, None, None, {"error": "test error"}), + # Test extracting text when JSON fails + (None, ValueError("Invalid JSON"), "Error text", {"text": "Error text"}), + # Test handling non-dict JSON response + ("simple string", None, None, {"text": "simple string"}), + # Test handling JSON decode error + ( + None, + ValueError("JSON decode error"), + "Raw response text", + {"text": "Raw response text"}, + ), + ], + ) + def test_extract_data_scenarios( + self, json_return_value, json_side_effect, text_value, expected_result + ): + """Test various data extraction scenarios.""" + mock_response = Mock() + mock_response.json.return_value = json_return_value + if json_side_effect: + mock_response.json.side_effect = json_side_effect + if text_value: + mock_response.text = text_value + + result = extract_error_data(mock_response) + + assert result == expected_result + if json_return_value is not None and json_side_effect is None: + mock_response.json.assert_called_once() + + +class TestParseTfeErrorResponse: + """Test cases for parse_tfe_error_response function.""" + + @pytest.mark.parametrize( + "response_data,expected_message,expected_errors,expected_code", + [ + # JSON:API format with multiple errors + ( + { + "errors": [ + {"detail": "Name has already been taken"}, + {"detail": "Email is invalid"}, + ] + }, + "Name has already been taken; Email is invalid", + ["Name has already been taken", "Email is invalid"], + None, + ), + # JSON:API format with error code + ( + { + "errors": [ + {"detail": "Name is required", "code": "VALIDATION_ERROR"} + ] + }, + "Name is required", + ["Name is required"], + "VALIDATION_ERROR", + ), + # JSON:API format with title fallback + ( + {"errors": [{"title": "Validation Failed", "code": "INVALID_DATA"}]}, + "Validation Failed", + ["Validation Failed"], + "INVALID_DATA", + ), + # Simple message format + ({"message": "Resource not found"}, "Resource not found", [], None), + # Error field format + ( + {"error": "Invalid request parameters"}, + "Invalid request parameters", + [], + None, + ), + # Empty errors list + ({"errors": []}, "Unknown API error", [], None), + # Unknown format + ({"unknown_field": "some value"}, "Unknown API error", [], None), + # Malformed response (string) + ("invalid json", "Unknown API error", [], None), + # None response + (None, "Failed to parse error response: None", [], None), + # Multiple error codes (should use first one) + ( + { + "errors": [ + {"detail": "First error", "code": "FIRST_CODE"}, + {"detail": "Second error", "code": "SECOND_CODE"}, + ] + }, + "First error; Second error", + ["First error", "Second error"], + "FIRST_CODE", + ), + # Non-dict error objects + ( + {"errors": ["Simple string error", {"detail": "Dict error"}]}, + "Dict error", + ["Dict error"], + None, + ), + ], + ) + def test_parse_various_formats( + self, response_data, expected_message, expected_errors, expected_code + ): + """Test parsing various error response formats.""" + result = parse_tfe_error_response(response_data) + + assert result["message"] == expected_message + assert result["errors"] == expected_errors + assert result["error_code"] == expected_code + + +class TestHandleHttpError: + """Test cases for handle_http_error function.""" + + @pytest.mark.parametrize( + "status_code,expected_exception_class,expected_message", + [ + ( + 401, + TFEUnauthorizedException, + "Authentication failed - invalid or missing token", + ), + (403, TFEForbiddenException, "Access forbidden - insufficient permissions"), + (404, TFENotFoundException, "Resource not found"), + (422, TFEValidationException, "Name is required; Email is invalid"), + (500, TFEServerException, "TFE server error"), + (502, TFEServerException, "TFE server error"), + (503, TFEServerException, "TFE server error"), + (504, TFEServerException, "TFE server error"), + (418, TFEEndpointException, "HTTP error occurred"), # Unknown status + ], + ) + def test_status_code_mapping( + self, status_code, expected_exception_class, expected_message + ): + """Test that status codes map to correct exception types.""" + method, path = "GET", "/test/path" + + mock_response = Mock() + mock_response.status_code = status_code + if status_code == 422: + mock_response.json.return_value = { + "errors": [ + {"detail": "Name is required"}, + {"detail": "Email is invalid"}, + ] + } + else: + mock_response.json.return_value = {"error": "Test error"} + + mock_http_error = exceptions.HTTPError(f"{status_code} Error") + mock_http_error.response = mock_response + + with pytest.raises(expected_exception_class) as exc_info: + handle_http_error(method, path, mock_http_error) + + exception = exc_info.value + assert exception.message == expected_message + assert exception.status_code == status_code + assert exception.method == method + assert exception.path == path + assert exception.cause == mock_http_error + + @pytest.mark.parametrize( + "method,path,json_response,expected_message", + [ + # Test 422 validation error with parsed error message + ( + "POST", + "/api/workspaces", + { + "errors": [ + {"detail": "Name is required"}, + {"detail": "Email format is invalid"}, + ] + }, + "Name is required; Email format is invalid", + ), + # Test 422 validation error with fallback message when parsing fails + ( + "POST", + "/api/workspaces", + {"unknown_format": "data"}, + "Unknown API error", # Actual fallback message from parse_tfe_error_response + ), + ], + ) + def test_validation_error_scenarios( + self, method, path, json_response, expected_message + ): + """Test various 422 validation error scenarios.""" + mock_response = Mock() + mock_response.status_code = 422 + mock_response.json.return_value = json_response + + mock_http_error = exceptions.HTTPError("422 Validation Error") + mock_http_error.response = mock_response + + with pytest.raises(TFEValidationException) as exc_info: + handle_http_error(method, path, mock_http_error) + + exception = exc_info.value + assert exception.message == expected_message + assert exception.status_code == 422 + assert exception.error_data == json_response + + def test_error_data_preservation(self): + """Test that error data is preserved in the exception.""" + method, path = "GET", "/test" + + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "error": "Bad Request", + "details": {"field": "value"}, + } + + mock_http_error = exceptions.HTTPError("400 Bad Request") + mock_http_error.response = mock_response + + with pytest.raises(TFEEndpointException) as exc_info: + handle_http_error(method, path, mock_http_error) + + exception = exc_info.value + assert exception.error_data == mock_response.json.return_value + assert exception.error_data["error"] == "Bad Request" + assert exception.error_data["details"]["field"] == "value" + + def test_logging_behavior(self, mocker): + """Test that appropriate logging occurs.""" + mock_logger = mocker.patch("tfe.error_utils.logger") + + method, path = "GET", "/test" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.return_value = {"error": "Not found"} + + mock_http_error = exceptions.HTTPError("404 Not Found") + mock_http_error.response = mock_response + + with pytest.raises(TFENotFoundException): + handle_http_error(method, path, mock_http_error) + + # Verify that error logging occurred + mock_logger.error.assert_called_once() + log_call_args = mock_logger.error.call_args[0] + assert "HTTP error while making" in log_call_args[0] + assert method in log_call_args[1] + assert path in log_call_args[2] + assert ( + log_call_args[3] == mock_http_error + ) # The error object is passed directly + assert log_call_args[4] == 404 # Status code is an integer diff --git a/tests/units/test_exception.py b/tests/units/test_exception.py new file mode 100644 index 00000000..79781954 --- /dev/null +++ b/tests/units/test_exception.py @@ -0,0 +1,143 @@ +"""Tests for the exception module.""" + +import pytest + +from tfe.exception import ( + TFEConnectionException, + TFEEndpointException, + TFEForbiddenException, + TFENotFoundException, + TFEServerException, + TFETimeoutException, + TFEUnauthorizedException, + TFEValidationException, +) + + +class TestTFEEndpointException: + """Test cases for TFEEndpointException base class.""" + + def test_exception_creation_and_properties(self): + """Test exception creation with various parameter combinations.""" + # Full creation + cause = ValueError("Original error") + error_data = {"error": "test"} + full = TFEEndpointException( + message="Test message", + status_code=400, + error_data=error_data, + cause=cause, + method="GET", + path="/test/path", + ) + assert full.status_code == 400 + assert full.error_data == error_data + assert full.cause == cause + assert full.method == "GET" + assert full.path == "/test/path" + + # Test string and repr representation + expected_str = ( + "Test message for GET /test/path (HTTP 400) (Error Data: {'error': 'test'})" + ) + expected_repr = "TFEEndpointException('Test message', status_code=400, method='GET', path='/test/path')" + assert str(full) == expected_str + assert repr(full) == expected_repr + + def test_exception_inheritance(self): + """Test that TFEEndpointException inherits from Exception.""" + exception = TFEEndpointException("Test message") + assert isinstance(exception, Exception) + assert isinstance(exception, TFEEndpointException) + + @pytest.mark.parametrize( + "message,status_code,method,path,error_data,expected_str", + [ + # Test with minimal info + ("Simple error", None, None, None, None, "Simple error"), + # Test with method and path + ( + "API error", + None, + "POST", + "/api/test", + None, + "API error for POST /api/test", + ), + # Test with status code + ("HTTP error", 404, None, None, None, "HTTP error (HTTP 404)"), + # Test with error data + ( + "Validation error", + None, + None, + None, + {"field": "name"}, + "Validation error (Error Data: {'field': 'name'})", + ), + # Test with all components + ( + "Complete error", + 422, + "PUT", + "/api/workspaces", + {"errors": ["Name is required"]}, + "Complete error for PUT /api/workspaces (HTTP 422) (Error Data: {'errors': ['Name is required']})", + ), + ], + ) + def test_error_message_building( + self, message, status_code, method, path, error_data, expected_str + ): + """Test error message building with different combinations.""" + exception = TFEEndpointException( + message=message, + status_code=status_code, + method=method, + path=path, + error_data=error_data, + ) + assert str(exception) == expected_str + + +class TestCustomEndpointExceptions: + """Test cases for custom endpoint exception classes.""" + + @pytest.mark.parametrize( + "exception_class,status_code", + [ + (TFEConnectionException, None), + (TFETimeoutException, None), + (TFEUnauthorizedException, 401), + (TFEForbiddenException, 403), + (TFENotFoundException, 404), + (TFEValidationException, 422), + (TFEServerException, 500), + ], + ) + def test_custom_exceptions(self, exception_class, status_code): + """Test all custom exception classes.""" + exception = exception_class( + message="Test message", method="GET", path="/test", status_code=status_code + ) + + assert exception.message == "Test message" + assert exception.status_code == status_code + assert isinstance(exception, TFEEndpointException) + assert isinstance(exception, Exception) + + def test_exception_with_error_data(self): + """Test exception with error data.""" + error_data = { + "errors": [{"detail": "Name is required"}, {"detail": "Email is invalid"}] + } + exception = TFEValidationException( + message="Validation failed", + status_code=422, + method="POST", + path="/api/workspaces", + error_data=error_data, + ) + + assert exception.error_data == error_data + assert exception.error_data["errors"][0]["detail"] == "Name is required" diff --git a/tfe/__init__.py b/tfe/__init__.py index 60fbccb2..e69de29b 100644 --- a/tfe/__init__.py +++ b/tfe/__init__.py @@ -1,12 +0,0 @@ -""" -Python client library for Terraform Enterprise/Cloud API. - -This package provides a Python interface to the Terraform Enterprise -and Terraform Cloud APIs, allowing you to programmatically manage -workspaces, runs, state files, and other TFE/TFC resources. -""" - -from tfe.client import Client, TFEClientError -from tfe.config import Config - -__all__ = ["Client", "TFEClientError", "Config"] diff --git a/tfe/endpoint.py b/tfe/endpoint.py new file mode 100644 index 00000000..37720612 --- /dev/null +++ b/tfe/endpoint.py @@ -0,0 +1,126 @@ +""" +Base service class for Terraform Enterprise/Cloud API services. + +This module provides an abstract base class that all TFE API services +should inherit from. It provides common functionality for HTTP requests +and defines the interface that all service implementations must follow. +""" + +import logging +from typing import Any + +from requests import Session, exceptions +from requests.models import Response as RequestResponse + +from tfe.error_utils import handle_http_error +from tfe.exception import ( + TFEConnectionException, + TFEEndpointException, + TFETimeoutException, +) + +logger = logging.getLogger(__name__) + + +class Endpoint: + """Base class for all TFE API services.""" + + def __init__(self, client: Session) -> None: + self._http_client = client + + def _make_request( + self, method: str, path: str, json: dict[str, Any] | None = None + ) -> RequestResponse: + """ + Make an HTTP request using the client's HTTP client. + + Args: + method: HTTP method (GET, POST, PUT, PATCH, DELETE) + path: API path + json: JSON payload for POST, PUT, PATCH requests + Returns: + The HTTP response from requests library + """ + method = method.upper() + response: RequestResponse | None = None + + try: + logger.debug("Making %s request to %s", method, path) + + # Make the request + match method: + case "GET": + response = self._http_client.get(path) + case "POST": + response = self._http_client.post(path, json=json) + case "PUT": + response = self._http_client.put(path, json=json) + case "PATCH": + response = self._http_client.patch(path, json=json) + case "DELETE": + response = self._http_client.delete(path) + case _: + raise TFEEndpointException( + message=f"Unsupported HTTP method: {method}", + method=method, + path=path, + ) + + # Check for HTTP errors and raise appropriate TFE exceptions + response.raise_for_status() + return response + + except exceptions.ConnectionError as e: + logger.error( + "Connection error while making %s request to %s: %s", method, path, e + ) + raise TFEConnectionException( + message="Failed to connect to TFE API", + method=method, + path=path, + cause=e, + ) from e + except exceptions.Timeout as e: + logger.error( + "Timeout error while making %s request to %s: %s", method, path, e + ) + raise TFETimeoutException( + message="Request timed out", method=method, path=path, cause=e + ) from e + except exceptions.HTTPError as e: + handle_http_error(method, path, e) + except exceptions.RequestException as e: + logger.error( + "Request error while making %s request to %s: %s", method, path, e + ) + raise TFEEndpointException( + message=f"Request failed: {str(e)}", + method=method, + path=path, + cause=e, + ) from e + except Exception as e: + logger.error( + "Unexpected error while making %s request to %s: %s", method, path, e + ) + raise TFEEndpointException( + message=f"Unexpected error occurred during {method} request", + method=method, + path=path, + cause=e, + ) from e + + def _get(self, path: str) -> RequestResponse: + return self._make_request("GET", path) + + def _post(self, path: str, data: dict) -> RequestResponse: + return self._make_request("POST", path, json=data) + + def _put(self, path: str, data: dict) -> RequestResponse: + return self._make_request("PUT", path, json=data) + + def _patch(self, path: str, data: dict) -> RequestResponse: + return self._make_request("PATCH", path, json=data) + + def _delete(self, path: str) -> RequestResponse: + return self._make_request("DELETE", path) diff --git a/tfe/error_utils.py b/tfe/error_utils.py new file mode 100644 index 00000000..3800efdf --- /dev/null +++ b/tfe/error_utils.py @@ -0,0 +1,114 @@ +""" +Utility functions for handling HTTP errors and parsing error responses in TFE API client. +""" + +import json +import logging +from typing import Any, NoReturn + +from requests import exceptions +from requests.models import Response as RequestResponse + +from tfe.exception import ( + TFEEndpointException, + TFEForbiddenException, + TFENotFoundException, + TFEServerException, + TFEUnauthorizedException, + TFEValidationException, +) + +logger = logging.getLogger(__name__) + + +def extract_error_data(response: RequestResponse | None) -> dict[str, Any] | None: + """Extract error data from HTTP response.""" + if not response: + return None + try: + result = response.json() + return result if isinstance(result, dict) else {"text": str(result)} + except (ValueError, json.JSONDecodeError): + return {"text": response.text} + + +def parse_tfe_error_response(response_data: dict[str, Any]) -> dict[str, Any]: + """ + Parse TFE API error response and extract meaningful error information. + """ + error_info: dict[str, Any] = { + "message": "Unknown API error", + "errors": [], + "error_code": None, + } + try: + if "errors" in response_data: + errors = response_data["errors"] + if isinstance(errors, list) and errors: + error_details = [] + for error in errors: + if isinstance(error, dict): + detail = error.get( + "detail", error.get("title", "Unknown error") + ) + error_details.append(detail) + if "code" in error and not error_info["error_code"]: + error_info["error_code"] = error["code"] + error_info["errors"] = error_details + error_info["message"] = "; ".join( + str(detail) for detail in error_details + ) + elif "message" in response_data: + error_info["message"] = response_data["message"] + elif "error" in response_data: + error_info["message"] = response_data["error"] + except (KeyError, TypeError, AttributeError) as e: + logger.warning("Failed to parse error response: %s", e) + error_info["message"] = f"Failed to parse error response: {response_data}" + return error_info + + +def handle_http_error(method: str, path: str, error: exceptions.HTTPError) -> NoReturn: + """ + Handle HTTP errors with specific status codes and raise appropriate exceptions. + """ + status_code = error.response.status_code if error.response else None + error_data = extract_error_data(error.response) + logger.error( + "HTTP error while making %s request to %s: %s (Status: %s)", + method, + path, + error, + status_code, + ) + STATUS_CODE_MAPPING: dict[int, tuple[type[TFEEndpointException], str]] = { + 401: ( + TFEUnauthorizedException, + "Authentication failed - invalid or missing token", + ), + 403: (TFEForbiddenException, "Access forbidden - insufficient permissions"), + 404: (TFENotFoundException, "Resource not found"), + 422: (TFEValidationException, "Validation failed"), + } + exception_class: type[TFEEndpointException] + if status_code and 500 <= status_code < 600: + exception_class = TFEServerException + message = "TFE server error" + else: + if status_code is not None: + exception_class, message = STATUS_CODE_MAPPING.get( + status_code, (TFEEndpointException, "HTTP error occurred") + ) + else: + exception_class, message = TFEEndpointException, "HTTP error occurred" + if status_code == 422: + parsed_errors = parse_tfe_error_response(error_data) if error_data else {} + message = parsed_errors.get("message", message) + raise exception_class( + message=message, + status_code=status_code, + error_data=error_data, + method=method, + path=path, + cause=error, + ) from error diff --git a/tfe/exception.py b/tfe/exception.py new file mode 100644 index 00000000..36823c61 --- /dev/null +++ b/tfe/exception.py @@ -0,0 +1,89 @@ +from typing import Any + + +class TFEEndpointException(Exception): + """Base exception for all TFE endpoint-related errors.""" + + def __init__( + self, + message: str, + status_code: int | None = None, + error_data: dict[str, Any] | None = None, + cause: Exception | None = None, + method: str | None = None, + path: str | None = None, + ) -> None: + self.message = message + self.status_code = status_code + self.error_data = error_data or {} + self.cause = cause + self.method = method + self.path = path + + # Build the full error message + full_message = self._build_error_message() + super().__init__(full_message) + + def _build_error_message(self) -> str: + """Build a comprehensive error message with request context.""" + parts = [self.message] + + if self.method and self.path: + parts.append(f"for {self.method} {self.path}") + + if self.status_code: + parts.append(f"(HTTP {self.status_code})") + + if self.error_data: + parts.append(f"(Error Data: {self.error_data})") + + return " ".join(parts) + + def __str__(self) -> str: + return self._build_error_message() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.message!r}, status_code={self.status_code}, method={self.method!r}, path={self.path!r})" + + +# Custom endpoint-specific exceptions +class TFEConnectionException(TFEEndpointException): + """Exception for connection-related errors.""" + + pass + + +class TFETimeoutException(TFEEndpointException): + """Exception for timeout errors.""" + + pass + + +class TFEUnauthorizedException(TFEEndpointException): + """Exception for 401 Unauthorized errors.""" + + pass + + +class TFEForbiddenException(TFEEndpointException): + """Exception for 403 Forbidden errors.""" + + pass + + +class TFENotFoundException(TFEEndpointException): + """Exception for 404 Not Found errors.""" + + pass + + +class TFEValidationException(TFEEndpointException): + """Exception for 422 Validation errors.""" + + pass + + +class TFEServerException(TFEEndpointException): + """Exception for 5xx server errors.""" + + pass