Source code for cardinity.client

"""
Cardinity HTTP Client

This module contains the HTTP client for making requests to the Cardinity API.
"""

import time
from typing import Any, Dict, Optional
from urllib.parse import urljoin

from requests import Response, Session
from requests.exceptions import ConnectionError, RequestException, Timeout

from .auth import CardinityAuth
from .exceptions import (
    APIError,
    AuthenticationError,
    CardinityError,
    NotFoundError,
    RateLimitError,
    ServerError,
)


[docs] class CardinityClient: """HTTP client for the Cardinity Payment Gateway API. This client handles all HTTP communication with the Cardinity API, including OAuth 1.0 authentication, request/response handling, error processing, and retry logic for transient failures. """ BASE_URL = "https://api.cardinity.com/v1" DEFAULT_TIMEOUT = 30 MAX_RETRIES = 3 RETRY_DELAY = 1 # seconds
[docs] def __init__( self, auth: CardinityAuth, base_url: str = BASE_URL, timeout: int = DEFAULT_TIMEOUT, max_retries: int = MAX_RETRIES, ) -> None: """Initialize the Cardinity HTTP client. Args: auth: CardinityAuth instance for authentication base_url: Base URL for the Cardinity API timeout: Request timeout in seconds max_retries: Maximum number of retries for failed requests """ self.auth = auth self.base_url = base_url self.timeout = timeout self.max_retries = max_retries # Create a persistent session for connection reuse self.session = Session() self.session.headers.update( { "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "cardinity-python/1.0.0", } )
def _should_retry( self, response: Optional[Response], exception: Optional[Exception] ) -> bool: """Determine if a request should be retried. Args: response: HTTP response object (if available) exception: Exception that occurred (if any) Returns: bool: True if the request should be retried """ # Retry on connection errors or timeouts if isinstance(exception, (ConnectionError, Timeout)): return True # Retry on server errors (5xx) but not client errors (4xx) if response and response.status_code >= 500: return True # Retry on rate limiting with exponential backoff if response and response.status_code == 429: return True return False def _build_url(self, endpoint: str) -> str: """Build the full URL for an API endpoint. Args: endpoint: API endpoint path (e.g., "/payments") Returns: str: Full URL for the API endpoint """ # Ensure base_url ends with / and endpoint starts with / for proper joining base = self.base_url.rstrip("/") + "/" endpoint = endpoint.lstrip("/") return urljoin(base, endpoint) def _parse_response(self, response: Response) -> Dict[str, Any]: """Parse and validate API response. Args: response: HTTP response object Returns: Dict[str, Any]: Parsed response data Raises: APIError: If the response indicates an error """ try: response_data = response.json() except ValueError: # Handle non-JSON responses response_data = {"error": "Invalid JSON response", "content": response.text} # Check for HTTP errors if not response.ok: error_message = self._extract_error_message(response_data, response) if response.status_code == 401: raise AuthenticationError(error_message) elif response.status_code == 404: raise NotFoundError(error_message) elif response.status_code == 429: raise RateLimitError(error_message) elif response.status_code >= 500: raise ServerError(error_message) else: raise APIError( message=error_message, status_code=response.status_code, response_data=response_data, ) return response_data def _extract_error_message( self, response_data: Dict[str, Any], response: Response ) -> str: """Extract error message from API response. Args: response_data: Parsed response data response: HTTP response object Returns: str: Error message """ # Try different common error message fields for field in ["message", "error", "detail", "error_description"]: if field in response_data and response_data[field]: return str(response_data[field]) # Fall back to generic HTTP status message return f"HTTP {response.status_code}: {response.reason}" def _request( self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retry_count: int = 0, ) -> Dict[str, Any]: """Make an HTTP request to the Cardinity API. Args: method: HTTP method (GET, POST, PATCH, DELETE) endpoint: API endpoint path data: Request payload data (for POST/PATCH requests) params: URL query parameters retry_count: Current retry attempt number Returns: Dict[str, Any]: Parsed API response data Raises: CardinityError: If the request fails after all retries """ url = self._build_url(endpoint) try: response = self.session.request( method=method.upper(), url=url, json=data, params=params, auth=self.auth.get_auth(), timeout=self.timeout, ) return self._parse_response(response) except (ConnectionError, Timeout, RequestException) as e: if retry_count < self.max_retries and self._should_retry(None, e): retry_count += 1 delay = self.RETRY_DELAY * ( 2 ** (retry_count - 1) ) # Exponential backoff time.sleep(delay) return self._request(method, endpoint, data, params, retry_count) raise CardinityError(f"Request failed: {str(e)}") except (RateLimitError, ServerError) as e: if retry_count < self.max_retries: retry_count += 1 delay = self.RETRY_DELAY * ( 2 ** (retry_count - 1) ) # Exponential backoff time.sleep(delay) return self._request(method, endpoint, data, params, retry_count) raise e
[docs] def get( self, endpoint: str, params: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Make a GET request to the API. Args: endpoint: API endpoint path params: URL query parameters Returns: Dict[str, Any]: API response data """ return self._request("GET", endpoint, params=params)
[docs] def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: """Make a POST request to the API. Args: endpoint: API endpoint path data: Request payload data Returns: Dict[str, Any]: API response data """ return self._request("POST", endpoint, data=data)
[docs] def patch(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: """Make a PATCH request to the API. Args: endpoint: API endpoint path data: Request payload data Returns: Dict[str, Any]: API response data """ return self._request("PATCH", endpoint, data=data)
[docs] def delete(self, endpoint: str) -> Dict[str, Any]: """Make a DELETE request to the API. Args: endpoint: API endpoint path Returns: Dict[str, Any]: API response data """ return self._request("DELETE", endpoint)
[docs] def execute_request(self, model) -> Dict[str, Any]: """Execute a request using a model object. Args: model: Model object with get_method(), get_endpoint(), and to_dict() methods Returns: Dict[str, Any]: Parsed API response data Raises: CardinityError: If the request fails """ method = model.get_method().upper() endpoint = model.get_endpoint() if method == "GET": return self.get(endpoint) elif method == "POST": return self.post(endpoint, model.to_dict()) elif method == "PATCH": return self.patch(endpoint, model.to_dict()) elif method == "DELETE": return self.delete(endpoint) else: raise ValueError(f"Unsupported HTTP method: {method}")
[docs] def close(self) -> None: """Close the HTTP session and clean up resources.""" if self.session: self.session.close()
[docs] def __enter__(self): """Context manager entry.""" return self
[docs] def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.close()
[docs] def __repr__(self) -> str: """Return a string representation of the client.""" return f"CardinityClient(base_url='{self.base_url}')"