193 lines
6.5 KiB
Python
193 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional
|
|
|
|
import aiohttp
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.crud.webhook import (
|
|
get_active_webhooks_for_event,
|
|
record_webhook_delivery,
|
|
update_webhook_stats,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class DeliveryResult:
|
|
"""Результат доставки webhook."""
|
|
|
|
webhook: Any
|
|
event_type: str
|
|
payload: dict[str, Any]
|
|
status: str
|
|
response_status: Optional[int] = None
|
|
response_body: Optional[str] = None
|
|
error_message: Optional[str] = None
|
|
|
|
|
|
class WebhookService:
|
|
"""Сервис для отправки webhooks."""
|
|
|
|
def __init__(self) -> None:
|
|
self._session: Optional[aiohttp.ClientSession] = None
|
|
|
|
async def _get_session(self) -> aiohttp.ClientSession:
|
|
"""Получить или создать HTTP сессию."""
|
|
if self._session is None or self._session.closed:
|
|
timeout = aiohttp.ClientTimeout(total=10, connect=5)
|
|
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
return self._session
|
|
|
|
async def close(self) -> None:
|
|
"""Закрыть HTTP сессию."""
|
|
if self._session and not self._session.closed:
|
|
await self._session.close()
|
|
|
|
def _sign_payload(self, payload: str, secret: str) -> str:
|
|
"""Подписать payload с помощью секрета."""
|
|
return hmac.new(
|
|
secret.encode("utf-8"),
|
|
payload.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
async def send_webhook(
|
|
self,
|
|
db: AsyncSession,
|
|
event_type: str,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
"""Отправить webhook для события."""
|
|
webhooks = await get_active_webhooks_for_event(db, event_type)
|
|
|
|
if not webhooks:
|
|
logger.debug("No active webhooks for event type: %s", event_type)
|
|
return
|
|
|
|
# Выполняем HTTP запросы параллельно (без операций с БД)
|
|
tasks = [
|
|
self._deliver_webhook_http(webhook, event_type, payload)
|
|
for webhook in webhooks
|
|
]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
# Записываем результаты в БД последовательно (избегаем concurrent session access)
|
|
for result in results:
|
|
if isinstance(result, Exception):
|
|
logger.exception("Unexpected error during webhook delivery: %s", result)
|
|
continue
|
|
if isinstance(result, DeliveryResult):
|
|
await self._record_result(db, result)
|
|
|
|
async def _deliver_webhook_http(
|
|
self,
|
|
webhook: Any,
|
|
event_type: str,
|
|
payload: dict[str, Any],
|
|
) -> DeliveryResult:
|
|
"""Выполнить HTTP доставку webhook (без операций с БД)."""
|
|
payload_json = json.dumps(payload, default=str, ensure_ascii=False)
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Webhook-Event": event_type,
|
|
"X-Webhook-Id": str(webhook.id),
|
|
}
|
|
|
|
# Добавляем подпись, если есть секрет
|
|
if webhook.secret:
|
|
signature = self._sign_payload(payload_json, webhook.secret)
|
|
headers["X-Webhook-Signature"] = f"sha256={signature}"
|
|
|
|
try:
|
|
session = await self._get_session()
|
|
async with session.post(
|
|
webhook.url,
|
|
data=payload_json,
|
|
headers=headers,
|
|
) as response:
|
|
response_body = await response.text()
|
|
# Ограничиваем размер ответа для хранения
|
|
if len(response_body) > 1000:
|
|
response_body = response_body[:1000] + "... (truncated)"
|
|
|
|
status = "success" if 200 <= response.status < 300 else "failed"
|
|
error_message = None
|
|
if status == "failed":
|
|
error_message = f"HTTP {response.status}: {response_body[:500]}"
|
|
|
|
return DeliveryResult(
|
|
webhook=webhook,
|
|
event_type=event_type,
|
|
payload=payload,
|
|
status=status,
|
|
response_status=response.status,
|
|
response_body=response_body,
|
|
error_message=error_message,
|
|
)
|
|
|
|
except asyncio.TimeoutError:
|
|
return DeliveryResult(
|
|
webhook=webhook,
|
|
event_type=event_type,
|
|
payload=payload,
|
|
status="failed",
|
|
error_message="Request timeout",
|
|
)
|
|
|
|
except Exception as error:
|
|
return DeliveryResult(
|
|
webhook=webhook,
|
|
event_type=event_type,
|
|
payload=payload,
|
|
status="failed",
|
|
error_message=str(error),
|
|
)
|
|
|
|
async def _record_result(self, db: AsyncSession, result: DeliveryResult) -> None:
|
|
"""Записать результат доставки в БД (последовательно)."""
|
|
try:
|
|
await record_webhook_delivery(
|
|
db,
|
|
webhook_id=result.webhook.id,
|
|
event_type=result.event_type,
|
|
payload=result.payload,
|
|
status=result.status,
|
|
response_status=result.response_status,
|
|
response_body=result.response_body,
|
|
error_message=result.error_message,
|
|
)
|
|
|
|
await update_webhook_stats(db, result.webhook, result.status == "success")
|
|
|
|
if result.status == "success":
|
|
logger.info(
|
|
"Webhook %s delivered successfully to %s",
|
|
result.webhook.id,
|
|
result.webhook.url,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Webhook %s delivery failed: %s",
|
|
result.webhook.id,
|
|
result.error_message,
|
|
)
|
|
except Exception as error:
|
|
logger.exception(
|
|
"Failed to record webhook delivery result for %s: %s",
|
|
result.webhook.id,
|
|
error,
|
|
)
|
|
|
|
|
|
# Глобальный экземпляр сервиса
|
|
webhook_service = WebhookService()
|
|
|