import logging from datetime import timedelta import requests from celery import shared_task from django.template import Context, Template from django.utils import timezone from infrasynth.shared.settings_utils import get_setting from .models import OutboundDelivery, OutboundSubscription from .signals import outbound_delivery_failed, outbound_delivery_succeeded from .signature import sign_payload logger = logging.getLogger(__name__) @shared_task( name="infrasynth.webhooks.deliver_webhook", bind=True, max_retries=5, default_retry_delay=60, ) def deliver_webhook(self, subscription_id, event_name, payload, payload_template): """Delivers an outbound webhook with HMAC signature and retry/backoff.""" try: subscription = OutboundSubscription.objects.select_related("endpoint").get(pk=subscription_id) except OutboundSubscription.DoesNotExist: logger.warning("Subscription %s not found", subscription_id) return None endpoint = subscription.endpoint body = _build_payload(payload, payload_template) delivery = OutboundDelivery.objects.create( subscription=subscription, payload=body, status=OutboundDelivery.Status.RETRYING, attempt=self.request.retries + 1, ) headers = {"Content-Type": "application/json"} headers.update(endpoint.headers or {}) headers[get_setting("INFRASYNTH_WEBHOOKS", "SIGNATURE_HEADER", "X-Webhook-Signature")] = sign_payload( endpoint.secret, body ) timeout = endpoint.timeout_seconds or get_setting("INFRASYNTH_WEBHOOKS", "DEFAULT_TIMEOUT_SECONDS", 10) try: response = requests.post( endpoint.url, data=body, headers=headers, timeout=timeout, ) except requests.RequestException as exc: return _handle_failure(self, delivery, subscription, endpoint, str(exc)) delivery.response_status = response.status_code delivery.response_body = response.text[:4000] delivery.completed_at = timezone.now() if 200 <= response.status_code < 300: delivery.status = OutboundDelivery.Status.SUCCESS delivery.save() outbound_delivery_succeeded.send( sender=OutboundDelivery, delivery_id=delivery.id, event_name=event_name, status_code=response.status_code, ) logger.info( "Webhook delivered: %s -> %s (%s)", event_name, endpoint.url, response.status_code, ) return delivery.id delivery.save() return _handle_failure( self, delivery, subscription, endpoint, f"HTTP {response.status_code}: {response.text[:500]}", ) def _build_payload(payload: dict, payload_template: str) -> str: import json if payload_template: try: template = Template(payload_template) return template.render(Context({"event": payload, "payload": payload})) except Exception: # noqa: BLE001 logger.exception("Failed to render webhook payload template") return json.dumps(payload, default=str) def _handle_failure(self, delivery, subscription, endpoint, error: str): max_retries = int( (endpoint.retry_policy or {}).get("max_retries", get_setting("INFRASYNTH_WEBHOOKS", "MAX_RETRIES", 5)) ) backoff = (endpoint.retry_policy or {}).get( "backoff", get_setting("INFRASYNTH_WEBHOOKS", "RETRY_BACKOFF", "exponential") ) initial_delay = int(get_setting("INFRASYNTH_WEBHOOKS", "RETRY_INITIAL_DELAY_SECONDS", 60)) delivery.status = OutboundDelivery.Status.RETRYING delivery.response_body = error[:4000] if not delivery.response_body else delivery.response_body if self.request.retries < max_retries: countdown = initial_delay * (2**self.request.retries) if backoff == "exponential" else initial_delay delivery.next_retry_at = timezone.now() + timedelta(seconds=countdown) delivery.save() logger.warning("Webhook delivery failed (attempt %s): %s", self.request.retries + 1, error) raise self.retry(exc=Exception(error), countdown=countdown) from None delivery.status = OutboundDelivery.Status.FAILED delivery.completed_at = timezone.now() delivery.save() outbound_delivery_failed.send( sender=OutboundDelivery, delivery_id=delivery.id, event_name=delivery.subscription.event_name, error=error, ) logger.error("Webhook delivery gave up: %s", error) return None