Throttle Outbound Cognigy Webhook Requests with Python Rate Limiting and Queue Management

Throttle Outbound Cognigy Webhook Requests with Python Rate Limiting and Queue Management

What You Will Build

  • A Python module that queues, validates, and throttles outbound HTTP requests to Cognigy webhook endpoints using configurable rate matrices, automatic queue shedding, and health verification pipelines.
  • This tutorial uses the Cognigy.AI REST API v2 (/api/v2/webhooks) and Python 3.10 with httpx and pydantic.
  • The code covers authentication, schema validation, async queue management, latency tracking, audit logging, and callback synchronization.

Prerequisites

  • Cognigy.AI OAuth2 client credentials with scopes webhooks:read and webhooks:write
  • Cognigy API version v2
  • Python 3.10 or higher
  • External dependencies: httpx==0.27.0, pydantic==2.6.0, asyncio (standard library)
  • Install dependencies: pip install httpx pydantic

Authentication Setup

Cognigy uses standard OAuth2 client credentials flow. The following code retrieves an access token and caches it with automatic refresh logic.

import httpx
import asyncio
import time
from typing import Optional
from dataclasses import dataclass, field

@dataclass
class CognigyAuthConfig:
    tenant: str
    client_id: str
    client_secret: str
    base_url: str = "https://{tenant}.cognigy.ai"
    token_url: str = "https://{tenant}.cognigy.ai/oauth/token"
    scopes: list[str] = field(default_factory=lambda: ["webhooks:read", "webhooks:write"])

class CognigyTokenManager:
    def __init__(self, config: CognigyAuthConfig):
        self.config = config
        self.base_url = config.base_url.format(tenant=config.tenant)
        self.token_url = config.token_url.format(tenant=config.tenant)
        self.access_token: Optional[str] = None
        self.token_expiry: float = 0.0
        self.client = httpx.AsyncClient(timeout=10.0)

    async def get_token(self) -> str:
        if self.access_token and time.time() < self.token_expiry - 30:
            return self.access_token

        payload = {
            "grant_type": "client_credentials",
            "client_id": self.config.client_id,
            "client_secret": self.config.client_secret,
            "scope": " ".join(self.config.scopes)
        }
        
        response = await self.client.post(self.token_url, data=payload)
        response.raise_for_status()
        
        token_data = response.json()
        self.access_token = token_data["access_token"]
        self.token_expiry = time.time() + token_data["expires_in"]
        return self.access_token

    async def close(self):
        await self.client.aclose()

Implementation

Step 1: Schema Validation and Payload Construction

Cognigy webhook payloads must conform to specific structural constraints. The following Pydantic models enforce format verification against gateway limits.

from pydantic import BaseModel, Field, field_validator
from typing import Any, Optional
import uuid

class CognigyWebhookPayload(BaseModel):
    webhook_id: str = Field(..., description="Unique identifier for the webhook endpoint")
    event_type: str = Field(..., pattern=r"^[a-zA-Z0-9_-]+$")
    payload_version: int = Field(default=2, ge=1, le=3)
    data: dict[str, Any] = Field(default_factory=dict)
    metadata: dict[str, Any] = Field(default_factory=dict)

    @field_validator("webhook_id")
    @classmethod
    def validate_webhook_id_format(cls, v: str) -> str:
        if len(v) > 128:
            raise ValueError("webhook_id exceeds maximum gateway constraint of 128 characters")
        return v

    @field_validator("data")
    @classmethod
    def validate_payload_size(cls, v: dict) -> dict:
        import json
        size_bytes = len(json.dumps(v).encode("utf-8"))
        if size_bytes > 256_000:
            raise ValueError("Payload exceeds 256KB maximum queue depth limit")
        return v

class ThrottleDirective(BaseModel):
    webhook_id: str
    rate_limit_matrix: dict[str, int] = Field(
        default_factory=lambda: {"requests_per_second": 10, "burst_allowance": 25}
    )
    queue_overflow_action: str = Field(default="shed", pattern=r"^(shed|queue|reject)$")
    max_queue_depth: int = Field(default=100, ge=1, le=1000)
    health_check_interval_sec: float = Field(default=30.0, gt=0)

Step 2: Rate Limit Matrix and Queue Management

This step implements atomic internal operations for request pacing. The queue enforces maximum depth limits and triggers automatic shedding when overflow occurs.

import asyncio
import logging
from collections import deque
from datetime import datetime, timezone

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cognigy_throttler")

class WebhookRequestQueue:
    def __init__(self, directive: ThrottleDirective):
        self.directive = directive
        self.queue: deque = deque(maxlen=directive.max_queue_depth)
        self.active_semaphore = asyncio.Semaphore(directive.rate_limit_matrix["burst_allowance"])
        self.last_request_time: float = 0.0
        self.request_count: int = 0
        self.window_start: float = time.time()
        
    async def enqueue(self, payload: CognigyWebhookPayload) -> bool:
        if len(self.queue) >= self.directive.max_queue_depth:
            logger.warning(
                f"Queue overflow detected for {self.directive.webhook_id}. "
                f"Action: {self.directive.queue_overflow_action}"
            )
            if self.directive.queue_overflow_action == "shed":
                dropped = self.queue.popleft()
                logger.info(f"Shed oldest request to maintain queue stability")
            elif self.directive.queue_overflow_action == "reject":
                return False
            elif self.directive.queue_overflow_action == "queue":
                logger.info("Queue at capacity. Holding new request until space frees.")
                await asyncio.sleep(0.1)
                return await self.enqueue(payload)
        
        self.queue.append({"payload": payload, "enqueued_at": time.time()})
        return True

    async def acquire_rate_token(self) -> None:
        await self.active_semaphore.acquire()
        
        now = time.time()
        if now - self.window_start >= 1.0:
            self.request_count = 0
            self.window_start = now
            
        if self.request_count >= self.directive.rate_limit_matrix["requests_per_second"]:
            sleep_time = 1.0 - (now - self.window_start)
            if sleep_time > 0:
                await asyncio.sleep(sleep_time)
                
        self.request_count += 1
        self.last_request_time = time.time()

    def release_rate_token(self) -> None:
        self.active_semaphore.release()

Step 3: Health Checking, Burst Verification, and Throttle Execution

This pipeline validates endpoint health, verifies burst allowance, executes the outbound request, and synchronizes throttling events with external monitors via callbacks.

from typing import Callable, Awaitable

HealthCallback = Callable[[dict], Awaitable[None]]

class CognigyWebhookThrottler:
    def __init__(
        self,
        auth: CognigyTokenManager,
        directive: ThrottleDirective,
        on_throttle_event: Optional[HealthCallback] = None
    ):
        self.auth = auth
        self.directive = directive
        self.queue_manager = WebhookRequestQueue(directive)
        self.callback = on_throttle_event
        self.audit_log: list[dict] = []
        self.success_count: int = 0
        self.failure_count: int = 0
        self.total_latency_ms: float = 0.0
        self.client = httpx.AsyncClient(timeout=15.0, limits=httpx.Limits(max_connections=50))

    async def check_endpoint_health(self, webhook_id: str) -> bool:
        try:
            token = await self.auth.get_token()
            url = f"{self.auth.base_url}/api/v2/webhooks/{webhook_id}"
            headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
            response = await self.client.get(url, headers=headers)
            return response.status_code == 200
        except httpx.HTTPStatusError as e:
            if e.response.status_code in (403, 401):
                logger.error(f"Authentication failure for {webhook_id}: {e}")
                return False
            logger.warning(f"Health check failed for {webhook_id}: {e}")
            return False
        except Exception as e:
            logger.error(f"Health check exception for {webhook_id}: {e}")
            return False

    async def dispatch_webhook(self, payload: CognigyWebhookPayload) -> dict:
        token = await self.auth.get_token()
        url = f"{self.auth.base_url}/api/v2/webhooks"
        headers = {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "X-Cognigy-Webhook-Id": payload.webhook_id
        }
        
        start_time = time.time()
        try:
            response = await self.client.post(url, headers=headers, json=payload.model_dump())
            response.raise_for_status()
            latency_ms = (time.time() - start_time) * 1000
            self.success_count += 1
            self.total_latency_ms += latency_ms
            
            result = {
                "status": "success",
                "http_status": response.status_code,
                "latency_ms": latency_ms,
                "webhook_id": payload.webhook_id
            }
            await self._record_audit(result)
            if self.callback:
                await self.callback(result)
            return result
            
        except httpx.HTTPStatusError as e:
            latency_ms = (time.time() - start_time) * 1000
            self.failure_count += 1
            self.total_latency_ms += latency_ms
            
            if e.response.status_code == 429:
                retry_after = float(e.response.headers.get("Retry-After", 1.0))
                logger.warning(f"Rate limited by provider. Waiting {retry_after}s")
                await asyncio.sleep(retry_after)
                
            result = {
                "status": "failure",
                "http_status": e.response.status_code,
                "latency_ms": latency_ms,
                "webhook_id": payload.webhook_id,
                "error": str(e)
            }
            await self._record_audit(result)
            if self.callback:
                await self.callback(result)
            return result
        except Exception as e:
            result = {
                "status": "error",
                "webhook_id": payload.webhook_id,
                "error": str(e)
            }
            await self._record_audit(result)
            raise

    async def _record_audit(self, event: dict) -> None:
        audit_entry = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "event_type": "throttle_dispatch",
            "webhook_id": event.get("webhook_id"),
            "outcome": event.get("status"),
            "latency_ms": event.get("latency_ms", 0),
            "success_rate": self._calculate_success_rate(),
            "avg_latency_ms": self._calculate_avg_latency()
        }
        self.audit_log.append(audit_entry)
        logger.info(f"Audit: {audit_entry}")

    def _calculate_success_rate(self) -> float:
        total = self.success_count + self.failure_count
        return (self.success_count / total * 100) if total > 0 else 0.0

    def _calculate_avg_latency(self) -> float:
        total = self.success_count + self.failure_count
        return (self.total_latency_ms / total) if total > 0 else 0.0

    async def run_throttle_loop(self) -> None:
        logger.info(f"Starting throttle loop for {self.directive.webhook_id}")
        is_healthy = await self.check_endpoint_health(self.directive.webhook_id)
        if not is_healthy:
            logger.error("Endpoint health check failed. Pausing dispatch loop.")
            return

        while True:
            if not self.queue_manager.queue:
                await asyncio.sleep(0.5)
                continue
                
            await self.queue_manager.acquire_rate_token()
            item = self.queue_manager.queue.popleft()
            payload = item["payload"]
            
            try:
                await self.dispatch_webhook(payload)
            except Exception as e:
                logger.error(f"Dispatch failed: {e}")
            finally:
                self.queue_manager.release_rate_token()

    async def close(self):
        await self.client.aclose()
        await self.auth.close()

Complete Working Example

The following script initializes the throttler, populates the queue, and runs the dispatch loop. Replace credentials with your Cognigy tenant values.

import asyncio
import logging

async def main():
    auth_config = CognigyAuthConfig(
        tenant="your-tenant-name",
        client_id="your_client_id",
        client_secret="your_client_secret"
    )
    
    directive = ThrottleDirective(
        webhook_id="wh_prod_cognigy_integration_01",
        rate_limit_matrix={"requests_per_second": 5, "burst_allowance": 10},
        queue_overflow_action="shed",
        max_queue_depth=50
    )

    async def monitor_callback(event: dict) -> None:
        logging.info(f"External Monitor Sync: {event['status']} | Latency: {event.get('latency_ms', 0):.2f}ms")

    auth_manager = CognigyTokenManager(auth_config)
    throttler = CognigyWebhookThrottler(
        auth=auth_manager,
        directive=directive,
        on_throttle_event=monitor_callback
    )

    try:
        sample_payload = CognigyWebhookPayload(
            webhook_id="wh_prod_cognigy_integration_01",
            event_type="session_update",
            payload_version=2,
            data={"session_id": "sess_8821", "user_state": "active", "context": {"lang": "en"}},
            metadata={"source": "automation_engine", "priority": "high"}
        )

        await throttler.queue_manager.enqueue(sample_payload)
        logging.info("Payload queued. Starting throttle execution.")
        await throttler.run_throttle_loop()
    except KeyboardInterrupt:
        logging.info("Throttle loop interrupted by user.")
    finally:
        await throttler.close()

if __name__ == "__main__":
    asyncio.run(main())

Common Errors & Debugging

Error: 401 Unauthorized

  • What causes it: Expired OAuth token, incorrect client credentials, or missing webhooks:write scope.
  • How to fix it: Verify the client_secret matches the Cognigy admin console configuration. Ensure the token refresh logic subtracts a 30-second buffer before expiry.
  • Code showing the fix: The CognigyTokenManager.get_token method automatically refreshes when time.time() >= self.token_expiry - 30.

Error: 429 Too Many Requests

  • What causes it: The Cognigy gateway enforces platform-level rate limits that exceed your local matrix configuration.
  • How to fix it: Parse the Retry-After header and pause the dispatch loop. The dispatch_webhook method already implements this fallback.
  • Code showing the fix:
if e.response.status_code == 429:
    retry_after = float(e.response.headers.get("Retry-After", 1.0))
    await asyncio.sleep(retry_after)

Error: Queue Overflow Exception

  • What causes it: Inbound payload generation exceeds the configured max_queue_depth faster than the dispatcher can process them.
  • How to fix it: Adjust the queue_overflow_action to shed or reject based on business tolerance. Increase max_queue_depth if memory permits.
  • Code showing the fix: The WebhookRequestQueue.enqueue method evaluates the directive action and either drops the oldest item or rejects the new one to prevent memory exhaustion.

Error: Pydantic ValidationError

  • What causes it: Payload exceeds 256KB, webhook_id exceeds 128 characters, or event_type contains invalid characters.
  • How to fix it: Validate data before instantiation. The field_validator decorators enforce gateway constraints explicitly.
  • Code showing the fix: Wrap payload creation in a try-except block and log the specific field violation before queuing.

Official References