api_client.py•7.49 kB
import logging
from typing import Any, Dict, Optional
import httpx
logger = logging.getLogger(__name__)
class APIClientError(Exception):
    """Base exception for API client errors."""
    pass
class APIHTTPError(APIClientError):
    """Raised when an HTTP error occurs during API request."""
    def __init__(
        self,
        message: str,
        status_code: int,
        response_text: str = "",
        endpoint: str = "",
    ):
        super().__init__(message)
        self.status_code = status_code
        self.response_text = response_text
        self.endpoint = endpoint
class APINetworkError(APIClientError):
    """Raised when a network/request error occurs during API request."""
    def __init__(self, message: str, endpoint: str = ""):
        super().__init__(message)
        self.endpoint = endpoint
class APITimeoutError(APINetworkError):
    """Raised when a timeout occurs during API request."""
    pass
class APIParseError(APIClientError):
    """Raised when there's an error parsing the API response."""
    def __init__(self, message: str, endpoint: str = "", response_text: str = ""):
        super().__init__(message)
        self.endpoint = endpoint
        self.response_text = response_text
class APIClient:
    """
    A client for making asynchronous API requests to the cBioPortal API.
    Manages an httpx.AsyncClient instance.
    """
    def __init__(
        self,
        base_url: str = "https://www.cbioportal.org/api",
        client_timeout: float = 480.0,
    ):
        """
        Initializes the APIClient.
        Args:
            base_url: The base URL for the cBioPortal API.
            client_timeout: Timeout in seconds for the HTTP client.
        """
        self.base_url = base_url.rstrip("/")
        self.client_timeout = client_timeout
        self._client: Optional[httpx.AsyncClient] = None
        logger.debug(
            f"APIClient initialized with base_url: {self.base_url}, timeout: {self.client_timeout}"
        )
    async def startup(self):
        """
        Initializes the asynchronous HTTP client.
        Should be called before making any API requests.
        """
        if self._client is None:
            self._client = httpx.AsyncClient(
                base_url=self.base_url, timeout=self.client_timeout
            )
            logger.info(
                f"APIClient's httpx.AsyncClient started with base_url: {self.base_url} and timeout: {self.client_timeout}s"
            )
        else:
            logger.info("APIClient's httpx.AsyncClient was already started.")
    async def shutdown(self):
        """
        Closes the asynchronous HTTP client.
        Should be called when the client is no longer needed.
        """
        if self._client:
            await self._client.aclose()
            self._client = None  # Mark as closed
            logger.info("APIClient's httpx.AsyncClient closed.")
        else:
            logger.info(
                "APIClient's httpx.AsyncClient was already closed or not initialized."
            )
    async def make_api_request(
        self,
        endpoint: str,
        method: str = "GET",
        params: Optional[Dict[str, Any]] = None,
        json_data: Optional[Any] = None,
    ) -> Any:
        """
        Makes an asynchronous API request to the cBioPortal API.
        Args:
            endpoint: The API endpoint path (e.g., "studies").
            method: HTTP method, "GET" or "POST".
            params: URL query parameters.
            json_data: JSON body for POST requests.
        Returns:
            The JSON response from the API.
        Raises:
            RuntimeError: If the client is not started.
            ValueError: If an unsupported HTTP method is provided.
            Exception: For API request failures (HTTP errors, request errors, etc.).
        """
        if self._client is None:
            raise RuntimeError(
                "APIClient._client is not initialized. Call APIClient.startup() before making requests."
            )
        # Use relative path since base_url is configured in the client
        endpoint_path = endpoint.lstrip("/")
        logger.debug(
            f"Making {method.upper()} request to {endpoint_path} with params: {params}, json_data: {json_data is not None}"
        )
        try:
            if method.upper() == "GET":
                response = await self._client.get(endpoint_path, params=params)
            elif method.upper() == "POST":
                response = await self._client.post(
                    endpoint_path, json=json_data, params=params
                )
            else:
                logger.error(
                    f"Unsupported HTTP method: {method} for endpoint: {endpoint_path}"
                )
                raise ValueError(f"Unsupported HTTP method: {method}")
            response.raise_for_status()  # Raises HTTPStatusError for 4xx/5xx responses
            if not response.text:  # Handle empty response body
                logger.debug(
                    f"Empty response body from {response.url} (status: {response.status_code}). Endpoint: {endpoint}"
                )
                # Original logic: if endpoint implies a list (plural 's' or 'fetch'), return empty list.
                if endpoint.endswith("s") or endpoint.endswith("fetch"):
                    return []
                return {}  # Otherwise, return empty dict.
            return response.json()
        except httpx.HTTPStatusError as e:
            error_text_snippet = (
                e.response.text[:500] if e.response.text else "No response body"
            )
            logger.error(
                f"HTTP error {e.response.status_code} for {method.upper()} {endpoint_path}: {error_text_snippet}..."
            )
            raise APIHTTPError(
                f"API request to {endpoint} failed with status {e.response.status_code}: {e.response.text}",
                status_code=e.response.status_code,
                response_text=e.response.text,
                endpoint=endpoint,
            ) from e
        except httpx.TimeoutException as e:
            logger.error(
                f"Timeout error for {method.upper()} {endpoint_path}: {str(e)}"
            )
            raise APITimeoutError(
                f"API request to {endpoint} timed out: {str(e)}", endpoint=endpoint
            ) from e
        except httpx.RequestError as e:  # Catches network errors, etc.
            logger.error(
                f"Request error for {method.upper()} {endpoint_path}: {str(e)}"
            )
            raise APINetworkError(
                f"API request to {endpoint} failed due to a network error: {str(e)}",
                endpoint=endpoint,
            ) from e
        except (ValueError, TypeError) as e:  # JSON decode errors, etc.
            logger.error(
                f"Parse error during API request to {method.upper()} {endpoint_path}: {str(e)}"
            )
            raise APIParseError(
                f"Failed to parse response from {endpoint}: {str(e)}", endpoint=endpoint
            ) from e
        except Exception as e:  # Catch-all for other unexpected errors
            logger.error(
                f"Unexpected error during API request to {method.upper()} {endpoint_path}: {str(e)}"
            )
            raise APIClientError(
                f"Unexpected error during API request to {endpoint}: {str(e)}"
            ) from e