132 lines
4.5 KiB
Python
132 lines
4.5 KiB
Python
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
|