ContentClientApiModule

Async-first collector helper that standardizes auth, retries, pagination, rate limiting, logging, and metrics for collector-style integrations.

python · ApiModules

Source

import asyncio
import concurrent.futures
import json
import random
import threading
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, UTC
from typing import (
    Any,
    Final,
)
from collections.abc import Callable, MutableMapping

import anyio
import demistomock as demisto
import httpx
from pydantic import BaseModel, Field  # pylint: disable=E0611
from CommonServerPython import *  # noqa: F401,F403
from CommonServerUserPython import *  # noqa: F401,F403

# Type aliases for better readability
HeadersType = dict[str, str]
ParamsType = dict[str, Any]
JsonType = dict[str, Any]
StatusCodesType = tuple[int, ...]

# Constants
DEFAULT_USER_AGENT: Final[str] = "ContentClient/1.0"


def _now() -> float:
    return time.monotonic()


def _ensure_dict(value: MutableMapping[str, Any] | None) -> dict[str, Any]:
    if value is None:
        return {}
    return dict(value)


def _get_value_by_path(obj: Any, path: str) -> Any:
    """Retrieve a value from a nested dictionary using dot notation.

    Args:
        obj: The dictionary or object to search.
        path: The dot-separated path (e.g., "data.items").

    Returns:
        The value at the path, or None if not found.
    """
    if not path or not isinstance(path, str):
        return obj

    current = obj
    for part in path.split("."):
        if not part:  # Skip empty parts from ".." or leading/trailing dots
            continue
        if isinstance(current, dict):
            current = current.get(part)
        elif isinstance(current, list):
            try:
                idx = int(part)
                if 0 <= idx < len(current):
                    current = current[idx]
                else:
                    return None
            except ValueError:
                return None
        else:
            return None

        if current is None:
            return None

    return current


def _extract_list(data: Any, path: str | None) -> list[Any]:
    extracted = _get_value_by_path(data, path) if path else data
    if extracted is None:
        return []
    if isinstance(extracted, list):
        return extracted
    if isinstance(extracted, dict):
        return [extracted]
    # Handle primitive types explicitly
    if isinstance(extracted, str | int | float | bool):
        return [extracted]
    return [extracted]


def _parse_retry_after(response: httpx.Response | None) -> float | None:
    if not response:
        return None
    retry_after = response.headers.get("Retry-After")
    if not retry_after:
        return None
    try:
        return float(retry_after)
    except ValueError:
        try:
            parsed = datetime.strptime(retry_after, "%a, %d %b %Y %H:%M:%S GMT").replace(tzinfo=UTC)
            return max(0.0, (parsed - datetime.now(UTC)).total_seconds())
        except ValueError:
            return None


# Error Classes


class ContentClientError(DemistoException):
    """Base error for all content client failures."""

    def __init__(self, message: str, response: httpx.Response | None = None):
        super().__init__(message)
        self.response = response


class ContentClientAuthenticationError(ContentClientError):
    """Raised when authentication cannot be completed."""


class ContentClientRateLimitError(ContentClientError):
    """Raised when the client hits a user-defined rate limit."""


class ContentClientTimeoutError(ContentClientError):
    """Raised when the execution timeout window is about to be exceeded."""


class ContentClientCircuitOpenError(ContentClientError):
    """Raised when the circuit breaker prevents additional requests."""


class ContentClientRetryError(ContentClientError):
    """Raised when requests exhaust all retry attempts."""


class ContentClientConfigurationError(ContentClientError):
    """Raised when the blueprint or request definition is invalid."""


@dataclass
class ClientExecutionMetrics:
    """Metrics for content client execution."""

    success: int = 0
    retry_error: int = 0
    quota_error: int = 0
    auth_error: int = 0
    service_error: int = 0
    general_error: int = 0


# Helper Classes
class RetryPolicy(BaseModel):
    """Retry policy for handling transient API failures.

    Configures exponential backoff with jitter for retrying failed requests.

    Attributes:
        max_attempts: Maximum number of retry attempts (default: 5).
        initial_delay: Initial delay in seconds before first retry (default: 1.0).
        multiplier: Multiplier for exponential backoff (default: 2.0).
        max_delay: Maximum delay between retries in seconds (default: 60.0).
        jitter: Random jitter factor (0.0-1.0) to prevent thundering herd (default: 0.2).
        retryable_status_codes: HTTP status codes that trigger a retry.
        retryable_exceptions: Exception types that trigger a retry.
        respect_retry_after: Whether to honor Retry-After headers (default: True).
    """

    class Config:
        extra = "forbid"
        arbitrary_types_allowed = True  # Required for Tuple[Type[Exception], ...]

    max_attempts: int = Field(5, ge=1)
    initial_delay: float = Field(1.0, ge=0)
    multiplier: float = Field(2.0, ge=1.0)
    max_delay: float = Field(60.0, gt=0)
    jitter: float = Field(0.2, ge=0.0, le=1.0)
    retryable_status_codes: tuple[int, ...] = (408, 413, 425, 429, 500, 502, 503, 504)
    retryable_exceptions: tuple[type[Exception], ...] = (
        httpx.ConnectError,
        httpx.ReadTimeout,
        httpx.WriteTimeout,
        httpx.RemoteProtocolError,
        httpx.PoolTimeout,
    )
    respect_retry_after: bool = True

    def next_delay(self, attempt: int, retry_after: float | None = None) -> float:
        """Calculate the next delay for retry with exponential backoff and jitter.

        Args:
            attempt: The current attempt number (1-based).
            retry_after: Optional server-provided retry-after value in seconds.

        Returns:
            The delay in seconds before the next retry attempt.
        """
        if retry_after is not None and self.respect_retry_after:
            return retry_after
        delay = min(self.max_delay, self.initial_delay * (self.multiplier ** (attempt - 1)))
        jitter_value = delay * self.jitter
        return max(0.0, delay + random.uniform(-jitter_value, jitter_value))


class CircuitBreakerPolicy(BaseModel):
    """Circuit breaker policy for preventing cascading failures."""

    class Config:
        extra = "forbid"

    failure_threshold: int = Field(5, ge=1)
    recovery_timeout: float = Field(60.0, gt=0)


class CircuitBreaker:
    """Circuit breaker implementation for preventing cascading failures.

    Tracks failures and opens the circuit when the failure threshold is reached,
    preventing further requests until the recovery timeout expires.

    This implementation uses a half-open state with probe request support:
    - CLOSED: Normal operation, requests are allowed
    - OPEN: Circuit is tripped, requests are blocked
    - HALF_OPEN: Recovery timeout expired, one probe request is allowed

    The circuit only fully closes after a successful probe request.

    This implementation is thread-safe using a threading lock.

    Attributes:
        policy: The circuit breaker policy configuration.
    """

    def __init__(self, policy: CircuitBreakerPolicy) -> None:
        self.policy: CircuitBreakerPolicy = policy
        self._failure_count: int = 0
        self._opened_at: float | None = None
        self._half_open: bool = False
        self._lock: threading.Lock = threading.Lock()

    def can_execute(self) -> bool:
        """Check if the circuit allows execution.

        Returns:
            True if the circuit is closed or half-open (allowing probe), False if open.
        """
        with self._lock:
            if self._opened_at is None:
                return True
            elapsed: float = _now() - self._opened_at
            if elapsed >= self.policy.recovery_timeout:
                # Enter half-open state - allow one probe request
                if not self._half_open:
                    self._half_open = True
                    return True
                # Already in half-open and probe is in progress, block additional requests
                return False
            return False

    def record_success(self) -> None:
        """Record a successful execution, resetting the failure count and closing the circuit."""
        with self._lock:
            self._failure_count = 0
            self._opened_at = None
            self._half_open = False

    def record_failure(self) -> None:
        """Record a failed execution, potentially opening the circuit."""
        with self._lock:
            self._failure_count += 1
            if self._half_open:
                # Probe failed, re-open the circuit
                self._opened_at = _now()
                self._half_open = False
            elif self._failure_count >= self.policy.failure_threshold:
                self._opened_at = _now()


class RateLimitPolicy(BaseModel):
    """Rate limiting policy using token bucket algorithm."""

    class Config:
        extra = "forbid"

    rate_per_second: float = Field(0.0, ge=0)
    burst: int = Field(1, ge=1)
    respect_retry_after: bool = True

    @property
    def enabled(self) -> bool:
        return self.rate_per_second > 0


class TokenBucketRateLimiter:
    """Token bucket rate limiter for controlling request rate.

    Uses the token bucket algorithm to limit the rate of requests.
    Tokens are refilled at a constant rate up to a maximum capacity (burst).

    This implementation uses threading.Lock for thread safety, which works
    correctly across different event loops and in synchronous contexts.

    Attributes:
        policy: The rate limit policy configuration.
    """

    def __init__(self, policy: RateLimitPolicy) -> None:
        self.policy: RateLimitPolicy = policy
        self._capacity: int = max(1, policy.burst)
        self._tokens: float = float(self._capacity)
        self._updated: float = _now()
        self._lock: threading.Lock = threading.Lock()  # Thread-safe lock

    async def acquire(self) -> None:
        """Acquire a token, waiting if necessary until one is available."""
        while True:
            with self._lock:
                self._refill_locked()
                if self._tokens >= 1:
                    self._tokens -= 1
                    return
                needed = 1 - self._tokens
                # Protect against division by zero - this should not happen in normal usage
                # since the rate limiter is only created when rate_per_second > 0
                rate = self.policy.rate_per_second
                if rate <= 0:
                    raise ContentClientConfigurationError("rate_per_second must be positive when rate limiting is enabled")
                wait_seconds = needed / rate
            await anyio.sleep(wait_seconds)

    def _refill_locked(self) -> None:
        """Refill tokens based on elapsed time (must be called with lock held)."""
        now = _now()
        delta = now - self._updated
        if delta <= 0:
            return
        refill = delta * self.policy.rate_per_second
        self._tokens = min(self._capacity, self._tokens + refill)
        self._updated = now


class TimeoutSettings(BaseModel):
    """Timeout configuration for HTTP requests and execution deadline."""

    class Config:
        extra = "forbid"

    connect: float = Field(10.0, gt=0)
    read: float = Field(60.0, gt=0)
    write: float = Field(60.0, gt=0)
    pool: float = Field(60.0, gt=0)
    execution: float | None = Field(None, gt=0)
    safety_buffer: float = Field(30.0, gt=0)

    def as_httpx(self) -> httpx.Timeout:
        return httpx.Timeout(connect=self.connect, read=self.read, write=self.write, pool=self.pool)


@dataclass
class ContentClientState:
    """Pagination and collection state for resuming after timeouts.

    Stores pagination position (cursor, page, offset) and metadata to allow seamless
    resumption of collection after timeouts or interruptions.

    Attributes:
        cursor: Current cursor value for cursor-based pagination.
        page: Current page number for page-based pagination.
        offset: Current offset value for offset-based pagination.
        last_event_id: Last processed event ID (for deduplication).
        partial_results: Events from incomplete pages (preserved on timeout).
        metadata: Custom metadata dictionary for storing additional state.
    """

    cursor: str | None = None
    page: int | None = None
    offset: int | None = None
    last_event_id: str | None = None
    partial_results: list[Any] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        """Convert state to a dictionary for serialization.

        Returns:
            Dictionary representation of the state.
        """
        return {
            "cursor": self.cursor,
            "page": self.page,
            "offset": self.offset,
            "last_event_id": self.last_event_id,
            "partial_results": self.partial_results,
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, raw: dict[str, Any] | None) -> "ContentClientState":
        """Create a ContentClientState from a dictionary.

        Args:
            raw: Dictionary containing state data.

        Returns:
            A new ContentClientState instance.
        """
        if not raw:
            return cls()
        return cls(
            cursor=raw.get("cursor"),
            page=raw.get("page"),
            offset=raw.get("offset"),
            last_event_id=raw.get("last_event_id"),
            partial_results=raw.get("partial_results", []),
            metadata=raw.get("metadata", {}),
        )


class ContentClientContextStore:
    """Store for persisting state to Demisto integration context.

    Provides read/write access to the integration context with retry logic
    for handling transient failures. Uses thread-safe locking to prevent
    race conditions during concurrent read-modify-write operations.

    Attributes:
        namespace: A namespace prefix for storing data (e.g., client name).
        max_retries: Maximum number of retry attempts for write operations.
    """

    def __init__(self, namespace: str, max_retries: int = 3) -> None:
        self.namespace: str = namespace
        self.max_retries: int = max_retries
        self._lock: threading.Lock = threading.Lock()

    def read(self) -> dict[str, Any]:
        """Read the current integration context.

        Returns:
            The integration context dictionary.
        """
        with self._lock:
            context: dict[str, Any] = demisto.getIntegrationContext() or {}
            return context

    def write(self, data: dict[str, Any]) -> None:
        """Write data to the integration context with retry logic.

        Uses thread-safe locking to prevent race conditions.

        Args:
            data: The data dictionary to write to the context.

        Raises:
            Exception: If all retry attempts fail.
        """
        last_error: Exception | None = None
        with self._lock:
            for attempt in range(self.max_retries):
                try:
                    demisto.setIntegrationContext(data)
                    return
                except Exception as e:
                    last_error = e
                    time.sleep(0.1 * (attempt + 1))  # Simple backoff
        if last_error:
            raise last_error


# Auth Handlers
class AuthHandler:
    """Abstract base class for authentication handlers."""

    name: str = "auth"

    async def on_request(self, client: "ContentClient", request: httpx.Request) -> None:
        """Modify the request to add authentication credentials."""
        raise NotImplementedError("Subclasses must implement on_request()")

    async def on_auth_failure(self, client: "ContentClient", response: httpx.Response) -> bool:
        """Handle authentication failure response."""
        return False


class APIKeyAuthHandler(AuthHandler):
    """Authentication handler for API key-based authentication.

    Supports adding the API key either as a header or as a query parameter.
    At least one of header_name or query_param must be provided.

    Args:
        key: The API key value.
        header_name: Optional header name to add the key to (e.g., 'X-API-Key').
        query_param: Optional query parameter name to add the key to (e.g., 'api_key').

    Raises:
        ContentClientConfigurationError: If neither header_name nor query_param is provided,
            or if the key is empty.
    """

    def __init__(self, key: str, header_name: str | None = None, query_param: str | None = None):
        super().__init__()
        if not key:
            raise ContentClientConfigurationError("APIKeyAuthHandler requires a non-empty key")
        if not header_name and not query_param:
            raise ContentClientConfigurationError("APIKeyAuthHandler requires header_name or query_param")
        self.key = key
        self.header_name = header_name
        self.query_param = query_param
        self.name = "api_key"

    async def on_request(self, client: "ContentClient", request: httpx.Request) -> None:
        if self.header_name:
            request.headers[self.header_name] = self.key
        if self.query_param:
            # Use copy_add_param for proper URL parameter handling
            request.url = request.url.copy_add_param(self.query_param, self.key)


class BearerTokenAuthHandler(AuthHandler):
    """Authentication handler for Bearer token authentication.

    Args:
        token: The bearer token value.

    Raises:
        ContentClientConfigurationError: If the token is empty.
    """

    def __init__(self, token: str):
        super().__init__()
        if not token:
            raise ContentClientConfigurationError("BearerTokenAuthHandler requires a non-empty token")
        self.token = token
        self.name = "bearer"

    async def on_request(self, client: "ContentClient", request: httpx.Request) -> None:
        request.headers["Authorization"] = f"Bearer {self.token}"


class BasicAuthHandler(AuthHandler):
    """Authentication handler for HTTP Basic Authentication.

    Args:
        username: The username for authentication.
        password: The password for authentication.

    Raises:
        ContentClientConfigurationError: If username is empty.
    """

    def __init__(self, username: str, password: str):
        super().__init__()
        if not username:
            raise ContentClientConfigurationError("BasicAuthHandler requires a non-empty username")
        credentials = f"{username}:{password}"
        self._encoded = b64_encode(credentials)
        self.name = "basic"

    async def on_request(self, client: "ContentClient", request: httpx.Request) -> None:
        request.headers["Authorization"] = f"Basic {self._encoded}"


class OAuth2ClientCredentialsHandler(AuthHandler):
    """Authentication handler for OAuth2 Client Credentials flow.

    Implements the OAuth2 client credentials grant type for machine-to-machine
    authentication. Automatically handles token refresh when tokens expire.

    Uses thread-safe locking to prevent race conditions during token refresh,
    which is important when the handler is used across different event loops
    or in synchronous contexts.

    Args:
        token_url: The OAuth2 token endpoint URL.
        client_id: The client ID for authentication.
        client_secret: The client secret for authentication.
        scope: Optional OAuth2 scope(s) to request.
        audience: Optional audience parameter for the token request.
        auth_params: Optional additional parameters to include in token requests.
        context_store: Optional context store for persisting tokens across executions.
        token_timeout: Timeout in seconds for token refresh requests (default: 30).

    Raises:
        ContentClientConfigurationError: If required parameters are missing or invalid.
    """

    def __init__(
        self,
        token_url: str,
        client_id: str,
        client_secret: str,
        scope: str | None = None,
        audience: str | None = None,
        auth_params: dict[str, str] | None = None,
        context_store: Any | None = None,
        token_timeout: float = 30.0,
    ):
        super().__init__()
        if not token_url:
            raise ContentClientConfigurationError("OAuth2ClientCredentialsHandler requires a non-empty token_url")
        if not client_id:
            raise ContentClientConfigurationError("OAuth2ClientCredentialsHandler requires a non-empty client_id")
        if not client_secret:
            raise ContentClientConfigurationError("OAuth2ClientCredentialsHandler requires a non-empty client_secret")

        self.token_url = token_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.scope = scope
        self.audience = audience
        self.auth_params = auth_params or {}
        self.context_store = context_store
        self.token_timeout = token_timeout
        self.name = "oauth2_client_credentials"

        self._access_token: str | None = None
        self._expires_at: float = 0
        self._lock: threading.Lock = threading.Lock()  # Thread-safe lock

        # Try to load cached token from context store
        if self.context_store:
            try:
                context = self.context_store.read()
                cached_token = context.get("oauth2_token", {})
                if cached_token.get("access_token") and cached_token.get("expires_at", 0) > _now():
                    self._access_token = cached_token["access_token"]
                    self._expires_at = cached_token["expires_at"]
            except Exception as e:
                # Log but don't fail if cache read fails
                demisto.debug(f"Failed to load cached OAuth2 token: {e}")

    async def on_request(self, client: "ContentClient", request: httpx.Request) -> None:
        # Check if refresh is needed (thread-safe read)
        if self._should_refresh():
            # Perform refresh outside the lock to avoid blocking async operations
            await self._refresh_token_if_needed(client)

        # Read token under lock to ensure visibility
        with self._lock:
            if self._access_token:
                request.headers["Authorization"] = f"Bearer {self._access_token}"

    async def on_auth_failure(self, client: "ContentClient", response: httpx.Response) -> bool:
        # If we get 401, force refresh token
        await self._force_refresh_token(client)
        return True

    def _should_refresh(self) -> bool:
        """Check if token needs refresh. Thread-safe read."""
        with self._lock:
            return not self._access_token or _now() >= self._expires_at - 60  # Refresh 60s before expiry

    async def _refresh_token_if_needed(self, client: "ContentClient") -> None:
        """Refresh token if needed, with proper locking that doesn't block async operations."""
        # Double-check pattern: check again after we're ready to refresh
        # We use a flag to track if we should refresh, then release the lock before awaiting
        should_refresh = False
        with self._lock:
            if self._should_refresh_unlocked():
                should_refresh = True

        if should_refresh:
            await self._refresh_token(client)

    async def _force_refresh_token(self, client: "ContentClient") -> None:
        """Force refresh the token (used after auth failure)."""
        await self._refresh_token(client)

    def _should_refresh_unlocked(self) -> bool:
        """Check if token needs refresh. Must be called with lock held."""
        return not self._access_token or _now() >= self._expires_at - 60

    async def _refresh_token(self, client: "ContentClient") -> None:
        """Refresh the OAuth2 access token.

        Uses a separate HTTP client for token refresh to avoid recursion/deadlocks
        and to not share state with the main client. This method should be called
        with the lock held.

        Args:
            client: The ContentClient instance (used for SSL verification settings).

        Raises:
            ContentClientAuthenticationError: If token refresh fails.
        """
        # Use a separate client for token refresh to avoid recursion/deadlocks
        # and to not share state with the main client
        async with httpx.AsyncClient(
            verify=client._verify, timeout=httpx.Timeout(self.token_timeout), follow_redirects=True
        ) as token_client:
            data = {
                "grant_type": "client_credentials",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
            }
            if self.scope:
                data["scope"] = self.scope
            if self.audience:
                data["audience"] = self.audience
            if self.auth_params:
                data.update(self.auth_params)

            try:
                response = await token_client.post(self.token_url, data=data)
                response.raise_for_status()
                token_data = response.json()

                self._access_token = token_data.get("access_token")
                if not self._access_token:
                    raise ContentClientAuthenticationError("No access_token in response")

                expires_in = token_data.get("expires_in", 3600)
                self._expires_at = _now() + expires_in

                # Persist token if context store is available
                if self.context_store:
                    try:
                        current_context = self.context_store.read()
                        current_context["oauth2_token"] = {"access_token": self._access_token, "expires_at": self._expires_at}
                        self.context_store.write(current_context)
                    except Exception as e:
                        # Log but don't fail auth if persistence fails
                        demisto.debug(f"Failed to persist OAuth2 token: {e}")

            except httpx.HTTPStatusError as e:
                raise ContentClientAuthenticationError(
                    f"Token refresh failed with status {e.response.status_code}: {e.response.text}"
                ) from e
            except httpx.TimeoutException as e:
                raise ContentClientAuthenticationError(f"Token refresh timed out: {str(e)}") from e
            except Exception as e:
                raise ContentClientAuthenticationError(f"Failed to refresh token: {str(e)}") from e


# Structured Logging for Google Cloud


class StructuredLogEntry:
    """Structured log entry optimized for Google Cloud Logs Explorer and BigQuery.

    Creates JSON-formatted log entries with consistent fields that can be easily
    queried in Google Cloud Logs Explorer and exported to BigQuery for analysis.

    Fields follow Google Cloud Logging conventions:
    - severity: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
    - message: Human-readable log message
    - timestamp: ISO 8601 formatted timestamp
    - labels: Key-value pairs for filtering (client_name, request_id, etc.)
    - httpRequest: HTTP request details (method, url, status, latency)
    - Additional custom fields for debugging
    """

    def __init__(self, severity: str, message: str, client_name: str, request_id: str | None = None, **kwargs: Any) -> None:
        self.entry: dict[str, Any] = {
            "severity": severity.upper(),
            "message": message,
            "timestamp": datetime.now(UTC).isoformat(),
            "labels": {
                "client_name": client_name,
                "component": "ContentClient",
            },
        }

        if request_id:
            self.entry["labels"]["request_id"] = request_id

        # Add any additional fields
        for key, value in kwargs.items():
            if key == "http_request":
                self.entry["httpRequest"] = value
            elif key == "error":
                self.entry["error"] = value
            elif key == "labels":
                self.entry["labels"].update(value)
            else:
                self.entry[key] = value

    def to_json(self) -> str:
        """Convert to JSON string for logging."""
        return json.dumps(self.entry, default=str)

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return self.entry


def create_http_request_log(
    method: str,
    url: str,
    status: int | None = None,
    latency_ms: float | None = None,
    request_size: int | None = None,
    response_size: int | None = None,
    user_agent: str | None = None,
) -> dict[str, Any]:
    """Create an httpRequest object following Google Cloud Logging format.

    This format is recognized by Google Cloud Logs Explorer and enables
    automatic HTTP request analysis and filtering.

    Args:
        method: HTTP method (GET, POST, etc.)
        url: Request URL
        status: HTTP response status code
        latency_ms: Request latency in milliseconds
        request_size: Size of request body in bytes
        response_size: Size of response body in bytes
        user_agent: User-Agent header value

    Returns:
        Dictionary in Google Cloud httpRequest format
    """
    http_request: dict[str, Any] = {
        "requestMethod": method.upper(),
        "requestUrl": url,
    }

    if status is not None:
        http_request["status"] = status

    if latency_ms is not None:
        # Google Cloud expects latency as a string with 's' suffix
        http_request["latency"] = f"{latency_ms / 1000:.3f}s"

    if request_size is not None:
        http_request["requestSize"] = str(request_size)

    if response_size is not None:
        http_request["responseSize"] = str(response_size)

    if user_agent:
        http_request["userAgent"] = user_agent

    return http_request


def create_error_log(
    error_type: str,
    error_message: str,
    stack_trace: str | None = None,
    error_code: str | None = None,
) -> dict[str, Any]:
    """Create an error object for structured logging.

    Args:
        error_type: Type/class of the error
        error_message: Error message
        stack_trace: Optional stack trace
        error_code: Optional error code for categorization

    Returns:
        Dictionary with error details
    """
    error: dict[str, Any] = {
        "type": error_type,
        "message": error_message,
    }

    if stack_trace:
        error["stackTrace"] = stack_trace

    if error_code:
        error["code"] = error_code

    return error


# Diagnostics


@dataclass
class RequestTrace:
    """Trace information for a single HTTP request."""

    method: str
    url: str
    headers: dict[str, str]
    params: dict[str, Any]
    body: Any | None
    timestamp: float
    response_status: int | None = None
    response_headers: dict[str, str] | None = None
    response_body: Any | None = None
    elapsed_ms: float | None = None
    error: str | None = None
    retry_attempt: int = 0


@dataclass
class DiagnosticReport:
    """Comprehensive diagnostic report for troubleshooting."""

    content_item_name: str
    configuration: dict[str, Any]
    request_traces: list[RequestTrace]
    state_snapshots: list[dict[str, Any]]
    performance_metrics: dict[str, Any]
    errors: list[dict[str, Any]]
    recommendations: list[str]
    timestamp: float


class ContentClientLogger:
    """Enhanced logger with diagnostic capabilities and structured logging.

    Provides structured JSON logging optimized for Google Cloud Logs Explorer
    and BigQuery analysis. Supports request tracing, performance metrics,
    and correlation IDs for distributed tracing.

    Attributes:
        client_name: Name of the client for log identification.
        diagnostic_mode: Whether to enable detailed diagnostic logging.
        structured_logging: Whether to use JSON structured logging (default: True).

    Example BigQuery queries:
        -- Find all errors for a specific client
        SELECT * FROM `project.dataset.logs`
        WHERE JSON_VALUE(jsonPayload.labels.client_name) = 'MyClient'
        AND severity = 'ERROR'

        -- Analyze request latencies
        SELECT
            JSON_VALUE(jsonPayload.httpRequest.requestUrl) as url,
            AVG(CAST(REPLACE(JSON_VALUE(jsonPayload.httpRequest.latency), 's', '') AS FLOAT64) * 1000) as avg_latency_ms
        FROM `project.dataset.logs`
        WHERE JSON_VALUE(jsonPayload.labels.component) = 'ContentClient'
        GROUP BY url

        -- Find requests by correlation ID
        SELECT * FROM `project.dataset.logs`
        WHERE JSON_VALUE(jsonPayload.labels.request_id) = 'abc-123'
        ORDER BY timestamp
    """

    def __init__(self, client_name: str, diagnostic_mode: bool = False, structured_logging: bool = True) -> None:
        self.client_name: str = client_name
        self.diagnostic_mode: bool = diagnostic_mode
        self.structured_logging: bool = structured_logging
        self._traces: list[RequestTrace] = []
        self._errors: list[dict[str, Any]] = []
        self._performance: dict[str, list[float]] = {
            "request_times": [],
            "pagination_times": [],
            "auth_times": [],
        }
        self._current_request_id: str | None = None

    def new_request_id(self) -> str:
        """Generate a new request ID for correlation.

        Returns:
            A unique request ID string.
        """
        self._current_request_id = str(uuid.uuid4())[:8]
        return self._current_request_id

    def get_request_id(self) -> str | None:
        """Get the current request ID.

        Returns:
            The current request ID or None if not set.
        """
        return self._current_request_id

    def debug(self, message: str, extra: dict[str, Any] | None = None) -> None:
        """Log a debug message (only in diagnostic mode)."""
        if self.diagnostic_mode:
            if self.structured_logging:
                log_entry = StructuredLogEntry(
                    severity="DEBUG",
                    message=message,
                    client_name=self.client_name,
                    request_id=self._current_request_id,
                    **(extra or {}),
                )
                demisto.debug(log_entry.to_json())
            else:
                demisto.debug(self._format("DEBUG", message, extra))

    def info(self, message: str, extra: dict[str, Any] | None = None) -> None:
        """Log an info message."""
        if self.structured_logging:
            log_entry = StructuredLogEntry(
                severity="INFO",
                message=message,
                client_name=self.client_name,
                request_id=self._current_request_id,
                **(extra or {}),
            )
            demisto.info(log_entry.to_json())
        else:
            demisto.info(self._format("INFO", message, extra))

    def warning(self, message: str, extra: dict[str, Any] | None = None) -> None:
        """Log a warning message."""
        if self.structured_logging:
            log_entry = StructuredLogEntry(
                severity="WARNING",
                message=message,
                client_name=self.client_name,
                request_id=self._current_request_id,
                **(extra or {}),
            )
            demisto.debug(log_entry.to_json())
        else:
            demisto.debug(self._format("WARNING", message, extra))

    def error(self, message: str, extra: dict[str, Any] | None = None) -> None:
        """Log an error message."""
        if self.structured_logging:
            error_info = None
            # Create a copy of extra to avoid modifying the original
            extra_copy = dict(extra) if extra else {}

            if extra_copy and "error_type" in extra_copy:
                error_info = create_error_log(
                    error_type=extra_copy.get("error_type", "unknown"),
                    error_message=extra_copy.get("error", message),
                )
                # Remove keys that are handled separately to avoid conflicts
                extra_copy.pop("error_type", None)
                extra_copy.pop("error", None)

            log_entry = StructuredLogEntry(
                severity="ERROR",
                message=message,
                client_name=self.client_name,
                request_id=self._current_request_id,
                error=error_info,
                **extra_copy,
            )
            demisto.error(log_entry.to_json())
        else:
            demisto.error(self._format("ERROR", message, extra))

        if self.diagnostic_mode and extra:
            self._errors.append({"message": message, "context": extra})

    def log_http_request(
        self,
        method: str,
        url: str,
        status: int | None = None,
        latency_ms: float | None = None,
        request_size: int | None = None,
        response_size: int | None = None,
        retry_attempt: int = 0,
        error: str | None = None,
    ) -> None:
        """Log an HTTP request with structured format for Google Cloud.

        This creates a log entry that can be easily queried in Logs Explorer
        and analyzed in BigQuery.

        Args:
            method: HTTP method
            url: Request URL
            status: HTTP response status code
            latency_ms: Request latency in milliseconds
            request_size: Size of request body in bytes
            response_size: Size of response body in bytes
            retry_attempt: Current retry attempt number
            error: Optional error message
        """
        http_request = create_http_request_log(
            method=method,
            url=url,
            status=status,
            latency_ms=latency_ms,
            request_size=request_size,
            response_size=response_size,
        )

        severity = "INFO"
        message = f"HTTP {method} {url}"

        if error:
            severity = "ERROR"
            message = f"HTTP {method} {url} failed: {error}"
        elif status and status >= 400:
            severity = "WARNING" if status < 500 else "ERROR"
            message = f"HTTP {method} {url} returned {status}"

        extra: dict[str, Any] = {
            "http_request": http_request,
            "retry_attempt": retry_attempt,
        }

        if error:
            extra["error"] = create_error_log(
                error_type="HTTPError",
                error_message=error,
            )

        log_entry = StructuredLogEntry(
            severity=severity, message=message, client_name=self.client_name, request_id=self._current_request_id, **extra
        )

        if severity == "ERROR":
            demisto.error(log_entry.to_json())
        elif severity == "WARNING":
            demisto.debug(log_entry.to_json())
        else:
            if self.diagnostic_mode:
                demisto.debug(log_entry.to_json())

    def log_metrics_summary(self) -> None:
        """Log a summary of execution metrics for analysis.

        This creates a structured log entry with aggregated metrics that can
        be used for monitoring and alerting in Google Cloud.
        """
        if not self._performance["request_times"]:
            return

        times = self._performance["request_times"]
        metrics = {
            "total_requests": len(times),
            "avg_latency_ms": sum(times) / len(times),
            "min_latency_ms": min(times),
            "max_latency_ms": max(times),
            "p50_latency_ms": sorted(times)[len(times) // 2] if times else 0,
            "p95_latency_ms": sorted(times)[int(len(times) * 0.95)] if len(times) >= 20 else max(times),
            "error_count": len(self._errors),
            "retry_count": sum(1 for t in self._traces if t.retry_attempt > 0),
        }

        log_entry = StructuredLogEntry(
            severity="INFO",
            message="Request metrics summary",
            client_name=self.client_name,
            request_id=self._current_request_id,
            metrics=metrics,
            labels={"metric_type": "summary"},
        )
        demisto.info(log_entry.to_json())

    def trace_request(
        self,
        method: str,
        url: str,
        headers: dict[str, str],
        params: dict[str, Any],
        body: Any | None = None,
        retry_attempt: int = 0,
    ) -> RequestTrace:
        """Create a trace record for an HTTP request.

        Args:
            method: HTTP method.
            url: Request URL.
            headers: Request headers (sensitive values will be redacted).
            params: Query parameters.
            body: Request body.
            retry_attempt: Current retry attempt number.

        Returns:
            A RequestTrace instance for tracking the request.
        """
        # Generate a new request ID for this request
        if retry_attempt == 0:
            self.new_request_id()

        # Redact sensitive headers for security
        safe_headers = headers.copy()
        sensitive_keys = ["Authorization", "X-API-Key", "X-Auth-Token", "API-Key", "Api-Key", "apikey"]
        for key in list(safe_headers.keys()):
            if key.lower() in [k.lower() for k in sensitive_keys]:
                safe_headers[key] = "***REDACTED***"

        trace = RequestTrace(
            method=method,
            url=url,
            headers=safe_headers,
            params=params.copy(),
            body=body,
            timestamp=_now(),
            retry_attempt=retry_attempt,
        )
        if self.diagnostic_mode:
            self._traces.append(trace)
            # Limit trace history to prevent memory issues
            if len(self._traces) > 1000:
                self._traces.pop(0)
        return trace

    def trace_response(
        self,
        trace: RequestTrace,
        status: int,
        headers: dict[str, str],
        body: Any,
        elapsed_ms: float,
    ) -> None:
        trace.response_status = status
        trace.response_headers = headers.copy()
        trace.response_body = body
        trace.elapsed_ms = elapsed_ms

        if self.diagnostic_mode:
            self._performance["request_times"].append(elapsed_ms)

            # Log with structured format for Google Cloud
            self.log_http_request(
                method=trace.method,
                url=trace.url,
                status=status,
                latency_ms=elapsed_ms,
                response_size=len(str(body)) if body else 0,
                retry_attempt=trace.retry_attempt,
            )

    def trace_error(
        self,
        trace: RequestTrace,
        error: str,
        elapsed_ms: float | None = None,
    ) -> None:
        trace.error = error
        if elapsed_ms:
            trace.elapsed_ms = elapsed_ms

        if self.diagnostic_mode:
            # Log with structured format for Google Cloud
            self.log_http_request(
                method=trace.method,
                url=trace.url,
                status=trace.response_status,
                latency_ms=elapsed_ms,
                retry_attempt=trace.retry_attempt,
                error=error,
            )

    def get_diagnostic_report(
        self,
        configuration: dict[str, Any],
        state_snapshots: list[dict[str, Any]] | None = None,
    ) -> DiagnosticReport:
        # Calculate performance metrics
        perf_metrics: dict[str, Any] = {}
        if self._performance["request_times"]:
            times = self._performance["request_times"]
            perf_metrics["avg_request_time_ms"] = sum(times) / len(times)
            perf_metrics["min_request_time_ms"] = min(times)
            perf_metrics["max_request_time_ms"] = max(times)
            perf_metrics["total_requests"] = len(times)

        # Generate recommendations
        recommendations: list[str] = []

        if self._errors:
            error_types: dict[str, int] = {}
            for err in self._errors:
                err_type = err.get("context", {}).get("error_type", "unknown")
                error_types[err_type] = error_types.get(err_type, 0) + 1

            if error_types.get("auth", 0) > 0:
                recommendations.append("Authentication errors detected. Check credentials and token expiration.")
            if error_types.get("rate_limit", 0) > 0:
                recommendations.append("Rate limit errors detected. Consider increasing rate_limit.rate_per_second.")
            if error_types.get("timeout", 0) > 0:
                recommendations.append("Timeout errors detected. Consider increasing timeout settings.")
            if error_types.get("network", 0) > 0:
                recommendations.append("Network errors detected. Check connectivity and proxy settings.")

        if perf_metrics.get("avg_request_time_ms", 0) > 5000:
            recommendations.append("Slow request times detected. Consider optimizing API calls or increasing timeouts.")

        if self._traces:
            retry_count = sum(1 for t in self._traces if t.retry_attempt > 0)
            if retry_count > len(self._traces) * 0.5:
                recommendations.append("High retry rate detected. Check API health and retry policy configuration.")

        return DiagnosticReport(
            content_item_name=self.client_name,
            configuration=configuration,
            request_traces=self._traces.copy(),
            state_snapshots=state_snapshots or [],
            performance_metrics=perf_metrics,
            errors=self._errors.copy(),
            recommendations=recommendations,
            timestamp=_now(),
        )

    def _format(self, level: str, message: str, extra: dict[str, Any] | None) -> str:
        if not extra:
            return f"[ContentClient:{self.client_name}:{level}] {message}"
        try:
            extra_str = json.dumps(extra)
        except (TypeError, ValueError):
            extra_str = str(extra)
        return f"[ContentClient:{self.client_name}:{level}] {message} | extra={extra_str}"


def _create_rate_limiter(policy: RateLimitPolicy | None) -> TokenBucketRateLimiter | None:
    """Create a rate limiter from policy if enabled.

    Factory function that encapsulates the rate limiter creation logic,
    improving readability and making the initialization more maintainable.

    Args:
        policy: Optional rate limit policy configuration.

    Returns:
        TokenBucketRateLimiter instance if policy is provided and enabled,
        None otherwise.
    """
    if policy is None:
        return None
    if not policy.enabled:
        return None
    return TokenBucketRateLimiter(policy)


class ContentClient:
    """Drop-in replacement for BaseClient with enhanced features.

    Fully compatible with BaseClient constructor and _http_request() method.
    Existing integrations can switch from BaseClient to ContentClient with zero code changes.

    Attributes:
        timeout: Default timeout for HTTP requests in seconds.
        execution_metrics: Metrics tracking for request outcomes.
        logger: Logger instance for diagnostic output.
    """

    def __init__(
        self,
        base_url: str,
        verify: bool = True,
        proxy: bool = False,
        ok_codes: StatusCodesType = (),
        headers: HeadersType | None = None,
        auth: tuple[str, str] | None = None,
        timeout: float = 60.0,
        # New optional parameters (backward compatible):
        auth_handler: AuthHandler | None = None,
        retry_policy: RetryPolicy | None = None,
        rate_limiter: RateLimitPolicy | None = None,
        circuit_breaker: CircuitBreakerPolicy | None = None,
        diagnostic_mode: bool = False,
        client_name: str = "ContentClient",
        is_multithreaded: bool = True,
        reuse_client: bool = True,
    ) -> None:
        """Initialize ContentClient with BaseClient-compatible parameters.

        Args:
            base_url: The base URL for all API requests.
            verify: Whether to verify SSL certificates.
            proxy: Whether to use system proxy settings.
            ok_codes: Tuple of HTTP status codes considered successful.
            headers: Default headers to include in all requests.
            auth: Authentication credentials (tuple of username/password or AuthBase).
            timeout: Default timeout for requests in seconds.
            auth_handler: Optional custom authentication handler.
            retry_policy: Optional retry policy configuration.
            rate_limiter: Optional rate limiting policy.
            circuit_breaker: Optional circuit breaker policy.
            diagnostic_mode: Whether to enable diagnostic logging.
                WARNING: intended for short, supervised debugging sessions only.
                Request traces retain full response bodies in memory, so leaving
                this enabled on high-volume or long-running integrations may
                exhaust container memory.
            client_name: Name for logging identification.
            is_multithreaded: Whether to enable multithreading support.
            reuse_client: Whether to reuse the HTTP client across sync requests (default: True).
                When True, the client is kept open for better performance.
                When False, the client is closed after each sync request to avoid event loop issues.
        """
        # Store BaseClient-compatible parameters
        self._base_url: str = base_url
        self._verify: bool = verify
        self._ok_codes: StatusCodesType = ok_codes
        self._headers: HeadersType = headers or {}
        self._auth: tuple[str, str] | None = auth
        self.timeout: float = timeout
        self._closed: bool = False
        self._is_multithreaded: bool = is_multithreaded
        self._reuse_client: bool = reuse_client

        # Handle proxy exactly like BaseClient
        if proxy:
            ensure_proxy_has_http_prefix()
        else:
            skip_proxy()
        if not verify:
            skip_cert_verification()

        # Enhanced features (optional, backward compatible)
        self._auth_handler: AuthHandler | None = auth_handler
        self._retry_policy: RetryPolicy = retry_policy if retry_policy is not None else RetryPolicy()  # type: ignore[call-arg]
        self._rate_limiter: TokenBucketRateLimiter | None = _create_rate_limiter(rate_limiter)
        self._circuit_breaker: CircuitBreaker = CircuitBreaker(
            circuit_breaker if circuit_breaker is not None else CircuitBreakerPolicy()  # type: ignore[call-arg]
        )
        self._diagnostic_mode = diagnostic_mode

        # Execution metrics (like BaseClient)
        self.execution_metrics = ClientExecutionMetrics()

        # Logger
        self.logger = ContentClientLogger(client_name, diagnostic_mode=diagnostic_mode)

        # httpx client for async operations - created lazily per event loop
        self._local_storage = threading.local()
        self._http2_available: bool = True  # Will be set to False if HTTP/2 fails

        if self._is_multithreaded:
            support_multithreading()

    def _get_async_client(self) -> httpx.AsyncClient:
        """Get or create an async client for the current event loop.

        This method ensures that the httpx.AsyncClient is created for and used
        within the same event loop, preventing issues when request_sync creates
        new event loops.

        Returns:
            An httpx.AsyncClient instance bound to the current event loop.
        """
        try:
            current_loop = asyncio.get_running_loop()
        except RuntimeError:
            current_loop = None

        # Check if we need to create a new client
        if (
            not hasattr(self._local_storage, "client")
            or self._local_storage.client is None
            or getattr(self._local_storage, "client_event_loop", None) != current_loop
        ):
            # Close existing client if any
            if getattr(self._local_storage, "client", None) is not None:
                try:
                    # Schedule close on the old loop if possible
                    old_loop = getattr(self._local_storage, "client_event_loop", None)
                    old_client = self._local_storage.client
                    if old_loop is not None and not old_loop.is_closed():
                        old_loop.call_soon_threadsafe(lambda c=old_client: asyncio.create_task(c.aclose()))
                except Exception:
                    pass  # Best effort cleanup

            # Create new client for current loop
            try:
                if self._http2_available:
                    self._local_storage.client = httpx.AsyncClient(
                        base_url=self._base_url.rstrip("/"),
                        timeout=self.timeout,
                        headers={"User-Agent": DEFAULT_USER_AGENT},
                        verify=self._verify,
                        http2=True,
                        follow_redirects=True,
                    )
            except ImportError:
                self._http2_available = False
                self.logger.info("HTTP/2 dependencies missing, falling back to HTTP/1.1 transport")

            if not self._http2_available or getattr(self._local_storage, "client", None) is None:
                self._local_storage.client = httpx.AsyncClient(
                    base_url=self._base_url.rstrip("/"),
                    timeout=self.timeout,
                    headers={"User-Agent": DEFAULT_USER_AGENT},
                    verify=self._verify,
                    http2=False,
                    follow_redirects=True,
                )

            self._local_storage.client_event_loop = current_loop

        return self._local_storage.client

    async def aclose(self) -> None:
        """Close the async HTTP client and release resources."""
        if self._closed:
            return
        self._closed = True
        if hasattr(self._local_storage, "client") and self._local_storage.client is not None:
            try:
                await self._local_storage.client.aclose()
            except Exception as e:
                self.logger.error(f"Error closing async client: {e}")
            finally:
                self._local_storage.client = None
                self._local_storage.client_event_loop = None

    def __enter__(self) -> "ContentClient":
        """Enter context manager."""
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        """Exit context manager and close resources."""
        self.close()

    async def __aenter__(self) -> "ContentClient":
        """Enter async context manager."""
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        """Exit async context manager and close resources."""
        await self.aclose()

    def close(self) -> None:
        """Close the client synchronously.

        Note: If called from within an async context, use aclose() instead.
        This method handles various event loop scenarios gracefully.
        """
        if self._closed:
            return

        try:
            # Try to get the current event loop
            try:
                loop = asyncio.get_running_loop()
                # We're in an async context - schedule the close
                self.logger.warning("close() called from async context. Use 'await client.aclose()' instead.")
                # Schedule the close but don't wait - the caller should use aclose()
                loop.call_soon(lambda: asyncio.create_task(self.aclose()))
                return
            except RuntimeError:
                # No running loop - we can safely run synchronously
                pass

            # Try to use existing event loop or create new one
            try:
                loop = asyncio.get_event_loop()
                if loop.is_closed():
                    raise RuntimeError("Event loop is closed")
                loop.run_until_complete(self.aclose())
            except RuntimeError:
                # No event loop or it's closed, create a new one
                asyncio.run(self.aclose())
        except Exception as e:
            self.logger.error(f"Error closing client: {e}")
            # Mark as closed even if there was an error to prevent repeated attempts
            self._closed = True

    async def _request(
        self,
        method: str,
        url_suffix: str = "",
        full_url: str | None = None,
        headers: HeadersType | None = None,
        auth: tuple[str, str] | None = None,
        json_data: JsonType | None = None,
        params: ParamsType | None = None,
        data: Any | None = None,
        files: dict[str, Any] | None = None,
        timeout: float | None = None,
        resp_type: str = "json",
        ok_codes: StatusCodesType | None = None,
        return_empty_response: bool = False,
        retries: int = 0,
        status_list_to_retry: list[int] | None = None,
        backoff_factor: float = 5.0,
        backoff_jitter: float = 0.0,
        raise_on_redirect: bool = False,
        raise_on_status: bool = False,
        error_handler: Callable[[httpx.Response], None] | None = None,
        empty_valid_codes: list[int] | None = None,
        params_parser: Callable[[ParamsType], ParamsType] | None = None,
        with_metrics: bool = False,
        **kwargs: Any,
    ) -> httpx.Response:
        """Execute an async HTTP request with retry and error handling.

        Args:
            method: HTTP method (GET, POST, PUT, PATCH, DELETE).
            url_suffix: URL path to append to base_url.
            full_url: Complete URL (overrides base_url + url_suffix).
            headers: Additional headers for this request.
            auth: Authentication tuple (username, password) for this request.
            json_data: JSON body data.
            params: Query parameters.
            data: Form data or raw body.
            files: Files to upload.
            timeout: Request timeout in seconds.
            resp_type: Expected response type ('json', 'text', 'content', 'response', 'xml').
            ok_codes: HTTP status codes considered successful.
            return_empty_response: Whether to return empty dict for empty responses.
            retries: Number of retry attempts (BaseClient compatibility).
            status_list_to_retry: Status codes that trigger retry.
            backoff_factor: Backoff multiplier for retries.
            backoff_jitter: Jitter factor for retry delays.
            raise_on_redirect: Whether to raise on redirect responses.
            raise_on_status: Whether to raise on non-2xx responses.
            error_handler: Custom error handler callback.
            empty_valid_codes: Status codes that return empty response.
            params_parser: Custom parameter parser function.
            with_metrics: Whether to include metrics in response.
            **kwargs: Additional arguments.

        Returns:
            The HTTP response object.

        Raises:
            ContentClientCircuitOpenError: If circuit breaker is open.
            ContentClientRateLimitError: If rate limit is exceeded.
            ContentClientAuthenticationError: If authentication fails.
            ContentClientRetryError: If all retry attempts are exhausted.
            ContentClientError: For other request failures.
        """

        if not self._circuit_breaker.can_execute():
            raise ContentClientCircuitOpenError("Circuit breaker is open, refusing to send request")

        if self._rate_limiter:
            await self._rate_limiter.acquire()

        # Prepare request
        url = full_url if full_url else urljoin(self._base_url, url_suffix)
        req_headers = self._headers.copy()
        if headers:
            req_headers.update(headers)
        if "User-Agent" not in req_headers:
            req_headers["User-Agent"] = DEFAULT_USER_AGENT

        req_params = _ensure_dict(params)

        attempt = 0
        last_error: Exception = Exception("Unknown error")
        trace: RequestTrace | None = None

        # Determine max attempts
        # If retries param is passed (BaseClient style), use it. Otherwise use retry_policy.
        max_attempts = retries + 1 if retries > 0 else self._retry_policy.max_attempts

        while attempt < max_attempts:
            attempt += 1
            start = _now()
            try:
                client = self._get_async_client()
                http_request = client.build_request(
                    method.upper(),
                    url=url,
                    params=req_params,
                    json=json_data,
                    data=data,
                    files=files,
                    headers=req_headers,
                    timeout=timeout or self.timeout,
                )

                if self._auth_handler:
                    await self._auth_handler.on_request(self, http_request)
                elif auth:
                    # Manual auth override
                    if isinstance(auth, tuple):
                        http_request.headers["Authorization"] = f"Basic {b64_encode(f'{auth[0]}:{auth[1]}')}"
                elif self._auth and isinstance(self._auth, tuple):
                    http_request.headers["Authorization"] = f"Basic {b64_encode(f'{self._auth[0]}:{self._auth[1]}')}"

                # Trace request if in diagnostic mode
                if self._diagnostic_mode:
                    full_req_url = str(http_request.url)
                    trace = self.logger.trace_request(
                        method=method.upper(),
                        url=full_req_url,
                        headers=dict(http_request.headers),
                        params=req_params,
                        body=json_data or data,
                        retry_attempt=attempt - 1,
                    )

                response = await client.send(http_request)
                elapsed_ms = (_now() - start) * 1000

                if self._diagnostic_mode and trace:
                    try:
                        response_body = response.json() if response.content else None
                    except Exception:
                        response_body = response.text[:1000]

                    self.logger.trace_response(
                        trace,
                        status=response.status_code,
                        headers=dict(response.headers),
                        body=response_body,
                        elapsed_ms=elapsed_ms,
                    )

                if response.status_code == 401 and self._auth_handler:
                    should_retry = await self._auth_handler.on_auth_failure(self, response)
                    if should_retry:
                        continue

                # Check for retryable status codes
                # Use status_list_to_retry if provided, else use policy
                retry_codes = status_list_to_retry if status_list_to_retry else self._retry_policy.retryable_status_codes

                if response.status_code in retry_codes:
                    raise httpx.HTTPStatusError("Retryable status", request=http_request, response=response)

                # Check for ok_codes
                # BaseClient logic: if ok_codes provided, check against it.
                # If not, check _ok_codes instance variable. Else check response.ok
                is_ok = False
                effective_ok_codes = ok_codes or self._ok_codes
                if effective_ok_codes:
                    is_ok = response.status_code in effective_ok_codes
                else:
                    is_ok = response.is_success

                if not is_ok:
                    # Raise exception to trigger error handling
                    response.raise_for_status()

                self.execution_metrics.success += 1
                self._circuit_breaker.record_success()
                self.logger.debug(  # noqa: PLE1205
                    "HTTP request completed",
                    {"status": response.status_code, "elapsed": elapsed_ms, "endpoint": url},
                )
                return response

            except ContentClientError:
                # Re-raise ContentClientError (including subclasses) without wrapping
                raise
            except (
                httpx.ConnectError,
                httpx.ReadTimeout,
                httpx.WriteTimeout,
                httpx.RemoteProtocolError,
                httpx.PoolTimeout,
            ) as exc:
                last_error = exc
                elapsed_ms = (_now() - start) * 1000
                if self._diagnostic_mode and trace:
                    self.logger.trace_error(trace, str(exc), elapsed_ms)

                should_retry = attempt < max_attempts
                if not should_retry:
                    break
                retry_after = _parse_retry_after(getattr(exc, "response", None))
                delay = self._retry_policy.next_delay(attempt, retry_after)
                self.execution_metrics.retry_error += 1
                self.logger.debug(  # noqa: PLE1205
                    "Retryable exception occurred",
                    {"attempt": attempt, "delay": delay, "error": str(exc), "error_type": type(exc).__name__},
                )
                await anyio.sleep(delay)
            except httpx.HTTPStatusError as exc:
                last_error = exc
                elapsed_ms = (_now() - start) * 1000
                if self._diagnostic_mode and trace:
                    try:
                        response_body = exc.response.json() if exc.response.content else None
                    except Exception:
                        response_body = exc.response.text[:1000]
                    self.logger.trace_response(
                        trace,
                        status=exc.response.status_code,
                        headers=dict(exc.response.headers),
                        body=response_body,
                        elapsed_ms=elapsed_ms,
                    )
                    self.logger.trace_error(trace, f"HTTP {exc.response.status_code}: {exc.response.text[:200]}")

                # Map HTTP status codes to specific exception types
                if exc.response.status_code == 429:
                    self.execution_metrics.quota_error += 1
                    self.logger.error("Rate limit error", {"status": 429, "error_type": "rate_limit"})  # noqa: PLE1205
                    should_retry = exc.response.status_code in (status_list_to_retry or self._retry_policy.retryable_status_codes)
                    if should_retry and attempt < max_attempts:
                        retry_after = _parse_retry_after(exc.response)
                        delay = self._retry_policy.next_delay(attempt, retry_after)
                        self.execution_metrics.retry_error += 1
                        await anyio.sleep(delay)
                        continue
                    self._circuit_breaker.record_failure()
                    raise ContentClientRateLimitError(f"Rate limit exceeded: {exc.response.text}", response=exc.response) from exc
                elif exc.response.status_code in (401, 403):
                    self.execution_metrics.auth_error += 1
                    self.logger.error(  # noqa: PLE1205
                        "Authentication error",
                        {"status": exc.response.status_code, "error_type": "auth"},
                    )
                    should_retry = exc.response.status_code in (status_list_to_retry or self._retry_policy.retryable_status_codes)
                    if should_retry and attempt < max_attempts:
                        retry_after = _parse_retry_after(exc.response)
                        delay = self._retry_policy.next_delay(attempt, retry_after)
                        self.execution_metrics.retry_error += 1
                        await anyio.sleep(delay)
                        continue
                    self._circuit_breaker.record_failure()
                    raise ContentClientAuthenticationError(
                        f"Authentication failed: {exc.response.text}", response=exc.response
                    ) from exc
                else:
                    self.execution_metrics.service_error += 1
                    self.logger.error(  # noqa: PLE1205
                        "Service error",
                        {"status": exc.response.status_code, "error_type": "service"},
                    )
                    should_retry = exc.response.status_code in (status_list_to_retry or self._retry_policy.retryable_status_codes)
                    if should_retry and attempt < max_attempts:
                        retry_after = _parse_retry_after(exc.response)
                        delay = self._retry_policy.next_delay(attempt, retry_after)
                        self.execution_metrics.retry_error += 1
                        await anyio.sleep(delay)
                        continue
                    self._circuit_breaker.record_failure()
                    raise ContentClientError(f"Request failed: {exc.response.text}", response=exc.response) from exc
            except Exception as exc:
                elapsed_ms = (_now() - start) * 1000
                if self._diagnostic_mode and trace:
                    self.logger.trace_error(trace, str(exc), elapsed_ms)

                self.execution_metrics.general_error += 1
                self._circuit_breaker.record_failure()
                self.logger.error(  # noqa: PLE1205
                    "Non-retryable exception occurred",
                    {"error": str(exc), "error_type": type(exc).__name__},
                )
                raise

        self._circuit_breaker.record_failure()
        last_response = getattr(last_error, "response", None)
        raise ContentClientRetryError(f"Exceeded retry attempts: {last_error}", response=last_response)

    def _http_request(
        self,
        method: str,
        url_suffix: str = "",
        full_url: str | None = None,
        headers: HeadersType | None = None,
        auth: tuple[str, str] | None = None,
        json_data: JsonType | None = None,
        params: ParamsType | None = None,
        data: Any | None = None,
        files: dict[str, Any] | None = None,
        timeout: float | None = None,
        resp_type: str = "json",
        ok_codes: StatusCodesType | None = None,
        return_empty_response: bool = False,
        retries: int = 0,
        status_list_to_retry: list[int] | None = None,
        backoff_factor: float = 5.0,
        backoff_jitter: float = 0.0,
        raise_on_redirect: bool = False,
        raise_on_status: bool = False,
        error_handler: Callable[[httpx.Response], None] | None = None,
        empty_valid_codes: list[int] | None = None,
        params_parser: Callable[[ParamsType], ParamsType] | None = None,
        with_metrics: bool = False,
        **kwargs: Any,
    ) -> Any:
        """Synchronous wrapper for _request to maintain BaseClient compatibility.

        This method provides the same interface as BaseClient._http_request().
        See _request() for full parameter documentation.

        Returns:
            The processed response based on resp_type parameter.
        """
        return self.request_sync(
            method=method,
            url_suffix=url_suffix,
            full_url=full_url,
            headers=headers,
            auth=auth,
            json_data=json_data,
            params=params,
            data=data,
            files=files,
            timeout=timeout,
            resp_type=resp_type,
            ok_codes=ok_codes,
            return_empty_response=return_empty_response,
            retries=retries,
            status_list_to_retry=status_list_to_retry,
            backoff_factor=backoff_factor,
            backoff_jitter=backoff_jitter,
            raise_on_redirect=raise_on_redirect,
            raise_on_status=raise_on_status,
            error_handler=error_handler,
            empty_valid_codes=empty_valid_codes,
            params_parser=params_parser,
            with_metrics=with_metrics,
            **kwargs,
        )

    def request_sync(self, *args: Any, **kwargs: Any) -> Any:
        """Execute a request synchronously.

        This method wraps the async _request method for synchronous usage.
        It handles response processing based on the resp_type parameter.

        The implementation creates a new event loop for each synchronous request
        to avoid issues with event loop reuse. The httpx.AsyncClient is automatically
        recreated for each new event loop.

        Args:
            *args: Positional arguments passed to _request.
            **kwargs: Keyword arguments passed to _request.

        Returns:
            The processed response based on resp_type (json, text, content, response, xml).
        """

        async def _do_request() -> Any:
            try:
                response = await self._request(*args, **kwargs)

                # Handle response processing (json, text, content) similar to BaseClient
                resp_type = kwargs.get("resp_type", "json")
                return_empty_response = kwargs.get("return_empty_response", False)
                empty_valid_codes = kwargs.get("empty_valid_codes")

                if return_empty_response and empty_valid_codes and response.status_code in empty_valid_codes:
                    return {}

                if resp_type == "json":
                    try:
                        return response.json()
                    except json.JSONDecodeError:
                        if not response.content:
                            return {}
                        raise
                elif resp_type == "text":
                    return response.text
                elif resp_type == "content":
                    return response.content
                elif resp_type == "response":
                    return response
                elif resp_type == "xml":
                    return response.text

                # Default fallback - try JSON
                try:
                    return response.json()
                except json.JSONDecodeError:
                    # If JSON parsing fails, return the response object
                    return response
            finally:
                # Clean up the client after each sync request only if reuse_client is False
                # When reuse_client is True (default), the client is kept open for better performance
                if not self._reuse_client and hasattr(self._local_storage, "client") and self._local_storage.client is not None:
                    try:
                        await self._local_storage.client.aclose()
                    except Exception:
                        pass
                    self._local_storage.client = None
                    self._local_storage.client_event_loop = None

        # Check if we're already in an async context
        try:
            asyncio.get_running_loop()
            # We're in an async context, need to use a thread pool
            with concurrent.futures.ThreadPoolExecutor() as executor:
                future = executor.submit(asyncio.run, _do_request())
                return future.result()
        except RuntimeError:
            # No running event loop, safe to use asyncio.run
            return asyncio.run(_do_request())

    # Standard HTTP verb helpers
    def get(self, url_suffix: str, params: ParamsType | None = None, **kwargs: Any) -> Any:
        """Execute a GET request.

        Args:
            url_suffix: URL path to append to base_url.
            params: Query parameters.
            **kwargs: Additional arguments passed to _http_request.

        Returns:
            The response object or processed response data.
        """
        if "resp_type" not in kwargs:
            kwargs["resp_type"] = "response"
        return self._http_request("GET", url_suffix, params=params, **kwargs)

    def post(self, url_suffix: str, json_data: JsonType | None = None, **kwargs: Any) -> Any:
        """Execute a POST request.

        Args:
            url_suffix: URL path to append to base_url.
            json_data: JSON body data.
            **kwargs: Additional arguments passed to _http_request.

        Returns:
            The response object or processed response data.
        """
        if "resp_type" not in kwargs:
            kwargs["resp_type"] = "response"
        return self._http_request("POST", url_suffix, json_data=json_data, **kwargs)

    def put(self, url_suffix: str, json_data: JsonType | None = None, **kwargs: Any) -> Any:
        """Execute a PUT request.

        Args:
            url_suffix: URL path to append to base_url.
            json_data: JSON body data.
            **kwargs: Additional arguments passed to _http_request.

        Returns:
            The response object or processed response data.
        """
        if "resp_type" not in kwargs:
            kwargs["resp_type"] = "response"
        return self._http_request("PUT", url_suffix, json_data=json_data, **kwargs)

    def patch(self, url_suffix: str, json_data: JsonType | None = None, **kwargs: Any) -> Any:
        """Execute a PATCH request.

        Args:
            url_suffix: URL path to append to base_url.
            json_data: JSON body data.
            **kwargs: Additional arguments passed to _http_request.

        Returns:
            The response object or processed response data.
        """
        if "resp_type" not in kwargs:
            kwargs["resp_type"] = "response"
        return self._http_request("PATCH", url_suffix, json_data=json_data, **kwargs)

    def delete(self, url_suffix: str, **kwargs: Any) -> Any:
        """Execute a DELETE request.

        Args:
            url_suffix: URL path to append to base_url.
            **kwargs: Additional arguments passed to _http_request.

        Returns:
            The response object or processed response data.
        """
        if "resp_type" not in kwargs:
            kwargs["resp_type"] = "response"
        return self._http_request("DELETE", url_suffix, **kwargs)

    @property
    def metrics(self) -> ClientExecutionMetrics:
        """Get the execution metrics for this client.

        Returns:
            The ClientExecutionMetrics instance tracking request outcomes.
        """
        return self.execution_metrics

    def get_diagnostic_report(self) -> DiagnosticReport:
        """Generate a diagnostic report for troubleshooting.

        Returns:
            A DiagnosticReport containing traces, metrics, and recommendations.
        """
        return self.logger.get_diagnostic_report(
            configuration={"name": self.logger.client_name, "base_url": self._base_url, "timeout": self.timeout}
        )

    def diagnose_error(self, error: Exception) -> dict[str, str]:
        """Diagnose an error and provide a solution recommendation.

        Args:
            error: The exception to diagnose.

        Returns:
            A dictionary with 'issue' and 'solution' keys.
        """
        if isinstance(error, ContentClientAuthenticationError):
            return {"issue": "Authentication failed", "solution": "Check credentials and token expiration."}
        if isinstance(error, ContentClientRateLimitError):
            return {"issue": "Rate limit exceeded", "solution": "Increase retry delay or request quota."}
        if isinstance(error, ContentClientTimeoutError):
            return {"issue": "Execution timeout", "solution": "Increase timeout settings or reduce batch size."}
        if isinstance(error, ContentClientCircuitOpenError):
            return {"issue": "Circuit breaker is open", "solution": "Wait for recovery timeout."}
        if isinstance(error, ContentClientRetryError):
            return {"issue": "All retry attempts exhausted", "solution": "Check API availability and retry policy."}
        if isinstance(error, ContentClientConfigurationError):
            return {"issue": "Configuration error", "solution": "Check integration parameters."}
        return {"issue": "Unexpected error", "solution": f"Check logs for details: {str(error)}"}

    def health_check(self) -> dict[str, Any]:
        """Perform a health check on the client.

        Returns:
            A dictionary with 'status', 'configuration_valid', 'warnings', and 'metrics'.
        """
        status: str = "healthy"
        warnings: list[str] = []

        if self.execution_metrics.auth_error > 0:
            status = "degraded"
            warnings.append("Authentication errors detected")
        if self.execution_metrics.general_error > 0:
            status = "degraded"
            warnings.append("General errors detected")
        if self.execution_metrics.quota_error > 0:
            status = "degraded"
            warnings.append("Rate limit errors detected")

        return {"status": status, "configuration_valid": True, "warnings": warnings, "metrics": self.execution_metrics}

README

ContentClient API Module

ContentClient is a drop-in replacement for BaseClient that provides enhanced reliability, observability, and developer experience features. It is designed to be fully backward compatible while offering powerful new capabilities for robust API integrations.

Table of Contents


Key Features

Feature Description
Drop-in Replacement Fully compatible with BaseClient constructor and _http_request method
Advanced Authentication Built-in handlers for API Key, Bearer Token, Basic Auth, and OAuth2 (with auto-refresh)
Retry Policy Configurable exponential backoff with jitter for transient failures
Rate Limiting Token bucket algorithm to respect API rate limits
Circuit Breaker Prevents cascading failures by temporarily blocking requests
Observability Detailed execution metrics, structured logging, and diagnostic reports
Async Core Built on httpx and anyio for high-performance async I/O

Installation

Import the module in your integration:

from ContentClientApiModule import *

Quick Start

Minimal Example

from ContentClientApiModule import ContentClient

# Create a client with minimal configuration
client = ContentClient(
    base_url="https://api.example.com",
    verify=True,
    proxy=False
)

# Make a simple GET request
response = client._http_request(
    method="GET",
    url_suffix="/users",
    params={"limit": 10}
)

# Response is automatically parsed as JSON
for user in response:
    print(f"User: {user['name']}")

Using HTTP Verb Helpers

# GET request
response = client.get("/users", params={"status": "active"})

# POST request with JSON body
new_user = client.post("/users", json_data={"name": "John", "email": "john@example.com"})

# PUT request
updated = client.put("/users/123", json_data={"name": "John Doe"})

# PATCH request
patched = client.patch("/users/123", json_data={"status": "inactive"})

# DELETE request
client.delete("/users/123")

Migration from BaseClient

Migrating from BaseClient to ContentClient requires zero code changes for basic usage. Simply replace the import and class name:

Before (BaseClient)

from CommonServerPython import BaseClient

class MyClient(BaseClient):
    def __init__(self, base_url, api_key, verify=True, proxy=False):
        super().__init__(
            base_url=base_url,
            verify=verify,
            proxy=proxy,
            headers={"Authorization": f"Bearer {api_key}"}
        )
    
    def get_incidents(self, limit=50):
        return self._http_request(
            method="GET",
            url_suffix="/incidents",
            params={"limit": limit}
        )

After (ContentClient - Zero Changes)

from ContentClientApiModule import ContentClient

class MyClient(ContentClient):
    def __init__(self, base_url, api_key, verify=True, proxy=False):
        super().__init__(
            base_url=base_url,
            verify=verify,
            proxy=proxy,
            headers={"Authorization": f"Bearer {api_key}"}  # Works exactly like BaseClient
        )
    
    def get_incidents(self, limit=50):
        # Same method signature - no changes needed!
        return self._http_request(
            method="GET",
            url_suffix="/incidents",
            params={"limit": limit}
        )

After (ContentClient - With Enhanced Auth)

Once migrated, you can optionally adopt enhanced features like built-in auth handlers:

from ContentClientApiModule import ContentClient, BearerTokenAuthHandler

class MyClient(ContentClient):
    def __init__(self, base_url, api_key, verify=True, proxy=False):
        super().__init__(
            base_url=base_url,
            verify=verify,
            proxy=proxy,
            # Optional enhancement: Use built-in auth handler
            auth_handler=BearerTokenAuthHandler(token=api_key)
        )
    
    def get_incidents(self, limit=50):
        # Same method signature - no changes needed!
        return self._http_request(
            method="GET",
            url_suffix="/incidents",
            params={"limit": limit}
        )

Gradual Enhancement

You can adopt enhanced features incrementally:

# Step 1: Just replace the class (zero changes)
class MyClient(ContentClient):
    pass

# Step 2: Add retry policy for reliability
class MyClient(ContentClient):
    def __init__(self, base_url, **kwargs):
        super().__init__(
            base_url=base_url,
            retry_policy=RetryPolicy(max_attempts=3),
            **kwargs
        )

# Step 3: Add rate limiting to respect API limits
class MyClient(ContentClient):
    def __init__(self, base_url, **kwargs):
        super().__init__(
            base_url=base_url,
            retry_policy=RetryPolicy(max_attempts=3),
            rate_limiter=RateLimitPolicy(rate_per_second=10),
            **kwargs
        )

# Step 4: Enable diagnostics for troubleshooting
class MyClient(ContentClient):
    def __init__(self, base_url, **kwargs):
        super().__init__(
            base_url=base_url,
            retry_policy=RetryPolicy(max_attempts=3),
            rate_limiter=RateLimitPolicy(rate_per_second=10),
            diagnostic_mode=True,
            client_name="MyIntegration",
            **kwargs
        )

Authentication Handlers

API Key Authentication

Add an API key to request headers or query parameters:

from ContentClientApiModule import ContentClient, APIKeyAuthHandler

# API key in header
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=APIKeyAuthHandler(
        key="your-api-key-here",
        header_name="X-API-Key"
    )
)

# API key in query parameter
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=APIKeyAuthHandler(
        key="your-api-key-here",
        query_param="api_key"
    )
)

# Both header and query parameter
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=APIKeyAuthHandler(
        key="your-api-key-here",
        header_name="X-API-Key",
        query_param="api_key"
    )
)

Bearer Token Authentication

Add a Bearer token to the Authorization header:

from ContentClientApiModule import ContentClient, BearerTokenAuthHandler

client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=BearerTokenAuthHandler(token="your-bearer-token")
)

# All requests will include: Authorization: Bearer your-bearer-token
response = client.get("/protected-resource")

Basic Authentication

Use HTTP Basic Authentication:

from ContentClientApiModule import ContentClient, BasicAuthHandler

client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=BasicAuthHandler(
        username="your-username",
        password="your-password"
    )
)

# All requests will include: Authorization: Basic <base64-encoded-credentials>
response = client.get("/protected-resource")

OAuth2 Client Credentials

Automatically handle OAuth2 token acquisition and refresh:

from ContentClientApiModule import (
    ContentClient,
    OAuth2ClientCredentialsHandler,
    ContentClientContextStore
)

# Basic OAuth2 setup
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=OAuth2ClientCredentialsHandler(
        token_url="https://auth.example.com/oauth/token",
        client_id="your-client-id",
        client_secret="your-client-secret"
    )
)

# With scope and audience
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=OAuth2ClientCredentialsHandler(
        token_url="https://auth.example.com/oauth/token",
        client_id="your-client-id",
        client_secret="your-client-secret",
        scope="read:data write:data",
        audience="https://api.example.com"
    )
)

# With token persistence (survives across execution runs)
context_store = ContentClientContextStore(namespace="MyIntegration")
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=OAuth2ClientCredentialsHandler(
        token_url="https://auth.example.com/oauth/token",
        client_id="your-client-id",
        client_secret="your-client-secret",
        context_store=context_store  # Tokens are persisted and reused
    )
)

# With additional auth parameters
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=OAuth2ClientCredentialsHandler(
        token_url="https://auth.example.com/oauth/token",
        client_id="your-client-id",
        client_secret="your-client-secret",
        auth_params={
            "resource": "https://api.example.com",
            "grant_type": "client_credentials"
        }
    )
)

Custom Authentication Handler

Create your own authentication handler for custom auth schemes:

from ContentClientApiModule import ContentClient, AuthHandler
import httpx

class CustomAuthHandler(AuthHandler):
    """Custom authentication using HMAC signature."""
    
    def __init__(self, api_key: str, secret_key: str):
        self.api_key = api_key
        self.secret_key = secret_key
        self.name = "custom_hmac"
    
    async def on_request(self, client: ContentClient, request: httpx.Request) -> None:
        import hmac
        import hashlib
        import time
        
        timestamp = str(int(time.time()))
        message = f"{request.method}{request.url.path}{timestamp}"
        signature = hmac.new(
            self.secret_key.encode(),
            message.encode(),
            hashlib.sha256
        ).hexdigest()
        
        request.headers["X-API-Key"] = self.api_key
        request.headers["X-Timestamp"] = timestamp
        request.headers["X-Signature"] = signature
    
    async def on_auth_failure(self, client: ContentClient, response: httpx.Response) -> bool:
        # Return True to retry the request after handling the failure
        # Return False to propagate the error
        return False

# Use the custom handler
client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=CustomAuthHandler(
        api_key="your-api-key",
        secret_key="your-secret-key"
    )
)

Resilience Policies

Retry Policy

Configure automatic retries with exponential backoff:

from ContentClientApiModule import ContentClient, RetryPolicy

# Default retry policy
client = ContentClient(
    base_url="https://api.example.com",
    retry_policy=RetryPolicy()  # Uses sensible defaults
)

# Custom retry policy
client = ContentClient(
    base_url="https://api.example.com",
    retry_policy=RetryPolicy(
        max_attempts=5,           # Maximum retry attempts (default: 5)
        initial_delay=1.0,        # Initial delay in seconds (default: 1.0)
        multiplier=2.0,           # Delay multiplier for exponential backoff (default: 2.0)
        max_delay=60.0,           # Maximum delay between retries (default: 60.0)
        jitter=0.2,               # Random jitter factor 0-1 (default: 0.2)
        respect_retry_after=True  # Honor Retry-After header (default: True)
    )
)

# Aggressive retry for critical operations
aggressive_retry = RetryPolicy(
    max_attempts=10,
    initial_delay=0.5,
    multiplier=1.5,
    max_delay=30.0,
    jitter=0.3
)

# Conservative retry for rate-limited APIs
conservative_retry = RetryPolicy(
    max_attempts=3,
    initial_delay=5.0,
    multiplier=3.0,
    max_delay=120.0,
    jitter=0.1,
    respect_retry_after=True
)

Retryable Status Codes

By default, the following HTTP status codes trigger a retry:

Code Description
408 Request Timeout
413 Payload Too Large
425 Too Early
429 Too Many Requests
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout

Retryable Exceptions

The following network exceptions trigger a retry:

  • httpx.ConnectError - Connection failed
  • httpx.ReadTimeout - Read operation timed out
  • httpx.WriteTimeout - Write operation timed out
  • httpx.RemoteProtocolError - Protocol error from server
  • httpx.PoolTimeout - Connection pool exhausted

Rate Limiting

Prevent hitting API rate limits using the token bucket algorithm:

from ContentClientApiModule import ContentClient, RateLimitPolicy

# Basic rate limiting: 10 requests per second
client = ContentClient(
    base_url="https://api.example.com",
    rate_limiter=RateLimitPolicy(
        rate_per_second=10.0,      # Sustained rate (default: 0 = disabled)
        burst=20,                   # Burst capacity (default: 1)
        respect_retry_after=True    # Honor Retry-After header (default: True)
    )
)

# Conservative rate limiting for strict APIs
client = ContentClient(
    base_url="https://api.example.com",
    rate_limiter=RateLimitPolicy(
        rate_per_second=1.0,  # 1 request per second
        burst=1               # No bursting allowed
    )
)

# High-throughput rate limiting
client = ContentClient(
    base_url="https://api.example.com",
    rate_limiter=RateLimitPolicy(
        rate_per_second=100.0,  # 100 requests per second
        burst=200               # Allow bursts up to 200 requests
    )
)

How Token Bucket Works

  1. The bucket starts with burst tokens
  2. Tokens are added at rate_per_second rate
  3. Each request consumes 1 token
  4. If no tokens available, the request waits until a token is available
  5. Maximum tokens in bucket is capped at burst

Circuit Breaker

Prevent cascading failures by temporarily blocking requests after repeated failures:

from ContentClientApiModule import ContentClient, CircuitBreakerPolicy

client = ContentClient(
    base_url="https://api.example.com",
    circuit_breaker=CircuitBreakerPolicy(
        failure_threshold=5,     # Open circuit after 5 failures (default: 5)
        recovery_timeout=60.0    # Try again after 60 seconds (default: 60.0)
    )
)

# Sensitive circuit breaker for critical services
client = ContentClient(
    base_url="https://api.example.com",
    circuit_breaker=CircuitBreakerPolicy(
        failure_threshold=3,     # Open after just 3 failures
        recovery_timeout=120.0   # Wait 2 minutes before retrying
    )
)

# Tolerant circuit breaker for less critical services
client = ContentClient(
    base_url="https://api.example.com",
    circuit_breaker=CircuitBreakerPolicy(
        failure_threshold=10,    # Allow more failures
        recovery_timeout=30.0    # Recover faster
    )
)

Circuit Breaker States

State Description
Closed Normal operation, requests are allowed
Open Requests are blocked, raises ContentClientCircuitOpenError
Half-Open After recovery timeout, one request is allowed to test the service

Combining Policies

Use all resilience policies together for maximum reliability:

from ContentClientApiModule import (
    ContentClient,
    BearerTokenAuthHandler,
    RetryPolicy,
    RateLimitPolicy,
    CircuitBreakerPolicy
)

client = ContentClient(
    base_url="https://api.example.com",
    auth_handler=BearerTokenAuthHandler(token="your-token"),
    
    # Retry transient failures
    retry_policy=RetryPolicy(
        max_attempts=5,
        initial_delay=1.0,
        multiplier=2.0
    ),
    
    # Respect API rate limits
    rate_limiter=RateLimitPolicy(
        rate_per_second=10.0,
        burst=20
    ),
    
    # Prevent cascading failures
    circuit_breaker=CircuitBreakerPolicy(
        failure_threshold=5,
        recovery_timeout=60.0
    ),
    
    # Enable diagnostics
    diagnostic_mode=True,
    client_name="MyIntegration"
)

Timeout Configuration

Configure request timeout settings:

from ContentClientApiModule import ContentClient

# Simple timeout (recommended)
client = ContentClient(
    base_url="https://api.example.com",
    timeout=60  # Timeout in seconds for all operations
)

# Quick timeout for fast APIs
client = ContentClient(
    base_url="https://api.example.com",
    timeout=10  # 10 second timeout
)

# Long timeout for slow APIs
client = ContentClient(
    base_url="https://api.example.com",
    timeout=300  # 5 minute timeout
)

TimeoutSettings (Advanced)

For advanced use cases, the TimeoutSettings class provides granular control over different timeout phases. This is primarily used internally but can be useful for understanding timeout behavior:

from ContentClientApiModule import TimeoutSettings

# TimeoutSettings structure (for reference)
timeout_config = TimeoutSettings(
    connect=10.0,        # Connection timeout in seconds (default: 10.0)
    read=60.0,           # Read timeout in seconds (default: 60.0)
    write=60.0,          # Write timeout in seconds (default: 60.0)
    pool=60.0,           # Connection pool timeout (default: 60.0)
    execution=300.0,     # Total execution timeout (default: None)
    safety_buffer=30.0   # Buffer before execution timeout (default: 30.0)
)

Note: The timeout parameter in ContentClient constructor accepts a float value representing seconds. The TimeoutSettings class is used internally for advanced timeout management.


Error Handling

Exception Hierarchy

ContentClientError (base)
├── ContentClientAuthenticationError    # 401, 403 responses
├── ContentClientRateLimitError         # 429 responses
├── ContentClientTimeoutError           # Execution timeout exceeded
├── ContentClientCircuitOpenError       # Circuit breaker is open
├── ContentClientRetryError             # All retry attempts exhausted
└── ContentClientConfigurationError     # Invalid configuration

Handling Errors

from ContentClientApiModule import (
    ContentClient,
    ContentClientError,
    ContentClientAuthenticationError,
    ContentClientRateLimitError,
    ContentClientTimeoutError,
    ContentClientCircuitOpenError,
    ContentClientRetryError,
    ContentClientConfigurationError
)

client = ContentClient(base_url="https://api.example.com")

try:
    response = client.get("/resource")
except ContentClientAuthenticationError as e:
    # Handle authentication failures (401, 403)
    demisto.error(f"Authentication failed: {e}")
    # Check credentials, refresh tokens, etc.
    
except ContentClientRateLimitError as e:
    # Handle rate limiting (429)
    demisto.error(f"Rate limit exceeded: {e}")
    # Wait and retry, or reduce request frequency
    
except ContentClientTimeoutError as e:
    # Handle execution timeout
    demisto.error(f"Operation timed out: {e}")
    # Consider increasing timeout or reducing batch size
    
except ContentClientCircuitOpenError as e:
    # Handle circuit breaker open
    demisto.error(f"Service unavailable (circuit open): {e}")
    # Wait for recovery or use fallback
    
except ContentClientRetryError as e:
    # Handle exhausted retries
    demisto.error(f"All retries exhausted: {e}")
    # Check service health, escalate if needed
    
except ContentClientConfigurationError as e:
    # Handle configuration errors
    demisto.error(f"Configuration error: {e}")
    # Fix integration parameters
    
except ContentClientError as e:
    # Handle any other client errors
    demisto.error(f"Client error: {e}")

Error Diagnosis

Use the built-in error diagnosis helper:

try:
    response = client.get("/resource")
except ContentClientError as e:
    diagnosis = client.diagnose_error(e)
    demisto.error(f"Issue: {diagnosis['issue']}")
    demisto.error(f"Solution: {diagnosis['solution']}")

Accessing Response Details

Error objects include the original response when available:

try:
    response = client.get("/resource")
except ContentClientError as e:
    if e.response:
        demisto.error(f"Status: {e.response.status_code}")
        demisto.error(f"Headers: {e.response.headers}")
        demisto.error(f"Body: {e.response.text}")

Metrics & Diagnostics

Execution Metrics

Track request statistics automatically:

from ContentClientApiModule import ContentClient

client = ContentClient(
    base_url="https://api.example.com",
    client_name="MyIntegration"
)

# Make some requests...
client.get("/users")
client.get("/orders")

# Access metrics
metrics = client.metrics
print(f"Successful requests: {metrics.success}")
print(f"Retry errors: {metrics.retry_error}")
print(f"Rate limit errors: {metrics.quota_error}")
print(f"Auth errors: {metrics.auth_error}")
print(f"Service errors: {metrics.service_error}")
print(f"General errors: {metrics.general_error}")

Diagnostic Mode

Warning: diagnostic_mode is for debugging only

This mode is intended for short, supervised debugging sessions only, such as a single fetch cycle or a command run on verbose log levels.

Request traces retain full response bodies in memory, so leaving diagnostic_mode enabled on high-volume or long-running integrations may exhaust container memory.

Enable detailed request tracing for troubleshooting:

from ContentClientApiModule import ContentClient

client = ContentClient(
    base_url="https://api.example.com",
    diagnostic_mode=True,  # Enable detailed tracing
    client_name="MyIntegration"
)

# Make requests...
try:
    client.get("/resource")
except Exception as e:
    pass

# Get comprehensive diagnostic report
report = client.get_diagnostic_report()

# Report includes:
# - collector_name: Client identifier
# - configuration: Client settings
# - request_traces: Detailed request/response logs
# - state_snapshots: State changes over time
# - performance_metrics: Timing statistics
# - errors: Error history
# - recommendations: Suggested fixes
# - timestamp: Report generation time

demisto.debug(f"Avg request time: {report.performance_metrics.get('avg_request_time_ms')}ms")
demisto.debug(f"Total requests: {report.performance_metrics.get('total_requests')}")

for recommendation in report.recommendations:
    demisto.debug(f"Recommendation: {recommendation}")

Health Check

Perform a health check on the client:

health = client.health_check()

print(f"Status: {health['status']}")  # 'healthy' or 'degraded'
print(f"Configuration valid: {health['configuration_valid']}")
print(f"Warnings: {health['warnings']}")
print(f"Metrics: {health['metrics']}")

# Example output:
# Status: degraded
# Configuration valid: True
# Warnings: ['Rate limit errors detected', 'Authentication errors detected']
# Metrics: ClientExecutionMetrics(success=45, retry_error=3, quota_error=2, ...)

Structured Logging

The client provides structured logging with context:

# Logs are automatically formatted with context
# [ContentClient:MyIntegration:DEBUG] HTTP request completed | extra={"status": 200, "elapsed": 150.5}
# [ContentClient:MyIntegration:ERROR] Authentication error | extra={"status": 401, "error_type": "auth"}

State Management

ContentClientState

Manage pagination and collection state for resumable operations:

from ContentClientApiModule import ContentClientState

# Create state for cursor-based pagination
state = ContentClientState(
    cursor="next_page_token_123",
    last_event_id="event_456"
)

# Create state for page-based pagination
state = ContentClientState(
    page=5,
    offset=100
)

# Store partial results (for timeout recovery)
state.partial_results = [{"id": 1}, {"id": 2}]

# Custom metadata
state.metadata = {
    "last_sync": "2024-01-15T10:30:00Z",
    "total_collected": 1500
}

# Serialize for storage
state_dict = state.to_dict()

# Restore from storage
restored_state = ContentClientState.from_dict(state_dict)

ContentClientContextStore

Persist state to Demisto integration context:

from ContentClientApiModule import ContentClientContextStore, ContentClientState

# Create a context store
store = ContentClientContextStore(namespace="MyIntegration")

# Read current context
context = store.read()

# Write state to context
state = ContentClientState(cursor="token_123")
context["pagination_state"] = state.to_dict()
store.write(context)

# Later, restore state
context = store.read()
state = ContentClientState.from_dict(context.get("pagination_state"))

Resumable Collection Example

from ContentClientApiModule import (
    ContentClient,
    ContentClientState,
    ContentClientContextStore,
    ContentClientTimeoutError
)

def collect_events(client: ContentClient, max_events: int = 1000):
    """Collect events with resumable state."""
    
    store = ContentClientContextStore(namespace="EventCollector")
    context = store.read()
    
    # Restore state from previous run
    state = ContentClientState.from_dict(context.get("collection_state"))
    
    events = state.partial_results.copy()
    
    try:
        while len(events) < max_events:
            # Build request with current state
            params = {"limit": 100}
            if state.cursor:
                params["cursor"] = state.cursor
            
            response = client.get("/events", params=params)
            data = response.json()
            
            # Process events
            new_events = data.get("events", [])
            events.extend(new_events)
            
            # Update state
            state.cursor = data.get("next_cursor")
            state.last_event_id = new_events[-1]["id"] if new_events else state.last_event_id
            
            # No more pages
            if not state.cursor:
                break
                
    except ContentClientTimeoutError:
        # Save partial results for next run
        state.partial_results = events
        context["collection_state"] = state.to_dict()
        store.write(context)
        raise
    
    # Clear state on successful completion
    context["collection_state"] = None
    store.write(context)
    
    return events

🔧 Complete Integration Example

Here’s a complete example of a production-ready integration:

from ContentClientApiModule import (
    ContentClient,
    OAuth2ClientCredentialsHandler,
    ContentClientContextStore,
    RetryPolicy,
    RateLimitPolicy,
    CircuitBreakerPolicy,
    TimeoutSettings,
    ContentClientError,
    ContentClientAuthenticationError,
    ContentClientRateLimitError
)
import demistomock as demisto
from CommonServerPython import *


class SecurityVendorClient(ContentClient):
    """Client for Security Vendor API."""
    
    def __init__(self, base_url: str, client_id: str, client_secret: str, verify: bool = True, proxy: bool = False):
        # Create context store for token persistence
        context_store = ContentClientContextStore(namespace="SecurityVendor")
        
        super().__init__(
            base_url=base_url,
            verify=verify,
            proxy=proxy,
            
            # OAuth2 authentication with token persistence
            auth_handler=OAuth2ClientCredentialsHandler(
                token_url=f"{base_url}/oauth/token",
                client_id=client_id,
                client_secret=client_secret,
                scope="read:alerts write:alerts",
                context_store=context_store
            ),
            
            # Retry policy for transient failures
            retry_policy=RetryPolicy(
                max_attempts=5,
                initial_delay=1.0,
                multiplier=2.0,
                max_delay=60.0
            ),
            
            # Rate limiting to respect API limits
            rate_limiter=RateLimitPolicy(
                rate_per_second=10.0,
                burst=20
            ),
            
            # Circuit breaker for fault tolerance
            circuit_breaker=CircuitBreakerPolicy(
                failure_threshold=5,
                recovery_timeout=60.0
            ),
            
            # Enable diagnostics
            diagnostic_mode=True,
            client_name="SecurityVendor"
        )
    
    def get_alerts(self, severity: str = None, limit: int = 50) -> List[Dict]:
        """Fetch security alerts."""
        params = {"limit": limit}
        if severity:
            params["severity"] = severity
        
        response = self._http_request(
            method="GET",
            url_suffix="/api/v1/alerts",
            params=params
        )
        return response.get("alerts", [])
    
    def get_alert_details(self, alert_id: str) -> Dict:
        """Get detailed information about an alert."""
        return self._http_request(
            method="GET",
            url_suffix=f"/api/v1/alerts/{alert_id}"
        )
    
    def update_alert_status(self, alert_id: str, status: str, comment: str = None) -> Dict:
        """Update alert status."""
        data = {"status": status}
        if comment:
            data["comment"] = comment
        
        return self._http_request(
            method="PATCH",
            url_suffix=f"/api/v1/alerts/{alert_id}",
            json_data=data
        )
    
    def search_alerts(self, query: str, start_time: str, end_time: str) -> List[Dict]:
        """Search alerts with a query."""
        return self._http_request(
            method="POST",
            url_suffix="/api/v1/alerts/search",
            json_data={
                "query": query,
                "start_time": start_time,
                "end_time": end_time
            }
        ).get("results", [])


def test_module(client: SecurityVendorClient) -> str:
    """Test the integration connection."""
    try:
        client.get_alerts(limit=1)
        return "ok"
    except ContentClientAuthenticationError:
        return "Authentication failed. Check your credentials."
    except ContentClientRateLimitError:
        return "Rate limit exceeded. Try again later."
    except ContentClientError as e:
        return f"Connection failed: {str(e)}"


def convert_severity(severity: str) -> int:
    """Convert vendor severity to XSOAR severity.
    
    Args:
        severity: Vendor severity string (e.g., 'low', 'medium', 'high', 'critical').
        
    Returns:
        XSOAR severity level (1-4).
    """
    severity_map = {
        "low": 1,
        "medium": 2,
        "high": 3,
        "critical": 4
    }
    return severity_map.get(severity.lower() if severity else "", 1)


def fetch_incidents(client: SecurityVendorClient, max_fetch: int = 50) -> List[Dict]:
    """Fetch incidents for XSOAR."""
    alerts = client.get_alerts(severity="high", limit=max_fetch)
    
    incidents = []
    for alert in alerts:
        incidents.append({
            "name": alert.get("title"),
            "occurred": alert.get("created_at"),
            "severity": convert_severity(alert.get("severity")),
            "rawJSON": json.dumps(alert)
        })
    
    return incidents


def get_alerts_command(client: SecurityVendorClient, args: Dict) -> CommandResults:
    """Get alerts command."""
    severity = args.get("severity")
    limit = arg_to_number(args.get("limit", 50))
    
    alerts = client.get_alerts(severity=severity, limit=limit)
    
    return CommandResults(
        outputs_prefix="SecurityVendor.Alert",
        outputs_key_field="id",
        outputs=alerts,
        readable_output=tableToMarkdown("Alerts", alerts)
    )


def main():
    params = demisto.params()
    command = demisto.command()
    args = demisto.args()
    
    try:
        client = SecurityVendorClient(
            base_url=params.get("url"),
            client_id=params.get("client_id"),
            client_secret=params.get("client_secret"),
            verify=not params.get("insecure", False),
            proxy=params.get("proxy", False)
        )
        
        if command == "test_module":  # Replace underscore with dash
            return_results(test_module(client))
        elif command == "fetch-incidents":
            incidents = fetch_incidents(client, params.get("max_fetch", 50))
            demisto.incidents(incidents)
        elif command == "security-vendor-get-alerts":
            return_results(get_alerts_command(client, args))
        else:
            raise NotImplementedError(f"Command {command} not implemented")
            
    except Exception as e:
        demisto.error(f"Error: {str(e)}")
        return_error(str(e))


if __name__ in ("__main__", "__builtin__", "builtins"):
    main()

API Reference

ContentClient

The main client class that extends BaseClient functionality.

Constructor Parameters

Parameter Type Default Description
base_url str Required Base URL for the API
verify bool True Verify SSL certificates
proxy bool False Use system proxy settings
ok_codes tuple () HTTP status codes to consider successful (empty tuple means use standard HTTP success codes)
headers Dict[str, str] None Default headers for all requests
auth tuple None Basic auth credentials (username, password)
timeout float 60.0 Request timeout in seconds
auth_handler AuthHandler None Authentication handler instance
retry_policy RetryPolicy None Retry policy configuration
rate_limiter RateLimitPolicy None Rate limiting configuration
circuit_breaker CircuitBreakerPolicy None Circuit breaker configuration
diagnostic_mode bool False Enable detailed request tracing
client_name str "ContentClient" Client identifier for logging

Methods

Method Description
_http_request(...) Make an HTTP request (BaseClient compatible)
get(url_suffix, ...) Make a GET request
post(url_suffix, ...) Make a POST request
put(url_suffix, ...) Make a PUT request
patch(url_suffix, ...) Make a PATCH request
delete(url_suffix, ...) Make a DELETE request
get_diagnostic_report() Get comprehensive diagnostic report
diagnose_error(error) Get diagnosis and solution for an error
health_check() Check client health status
close() Close the client and release resources

RetryPolicy

Configuration for automatic request retries.

Parameter Type Default Description
max_attempts int 5 Maximum number of retry attempts
initial_delay float 1.0 Initial delay between retries (seconds)
multiplier float 2.0 Delay multiplier for exponential backoff
max_delay float 60.0 Maximum delay between retries (seconds)
jitter float 0.2 Random jitter factor (0.0 to 1.0)
respect_retry_after bool True Honor Retry-After header from server

RateLimitPolicy

Configuration for request rate limiting.

Parameter Type Default Description
rate_per_second float 0.0 Requests per second (0 = disabled)
burst int 1 Maximum burst capacity
respect_retry_after bool True Honor Retry-After header from server

CircuitBreakerPolicy

Configuration for circuit breaker pattern.

Parameter Type Default Description
failure_threshold int 5 Failures before opening circuit
recovery_timeout float 60.0 Seconds before attempting recovery

TimeoutSettings

Granular timeout configuration.

Parameter Type Default Description
connect float 10.0 Connection timeout (seconds)
read float 60.0 Read timeout (seconds)
write float 60.0 Write timeout (seconds)
pool float 60.0 Connection pool timeout (seconds)
execution float None Total execution timeout (seconds)
safety_buffer float 30.0 Buffer before execution timeout

Authentication Handlers

Handler Parameters
APIKeyAuthHandler key, header_name, query_param
BearerTokenAuthHandler token
BasicAuthHandler username, password
OAuth2ClientCredentialsHandler token_url, client_id, client_secret, scope, audience, auth_params, context_store

Exceptions

Exception Description
ContentClientError Base exception for all client errors
ContentClientAuthenticationError Authentication failed (401, 403)
ContentClientRateLimitError Rate limit exceeded (429)
ContentClientTimeoutError Execution timeout exceeded
ContentClientCircuitOpenError Circuit breaker is open
ContentClientRetryError All retry attempts exhausted
ContentClientConfigurationError Invalid configuration