Отпечаток секрета (sha256, 8 символов) в /health по токену: две стороны сверяют настройки, не пересылая значение. В журнал доставки добавлены размер тела, его sha256, начало подписи и текст ответа - по ним видно, расходится секрет или принимающая сторона считает подпись не от сырых байтов. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
4.5 KiB
Python
85 lines
4.5 KiB
Python
"""Отправка готового результата на сторонний адрес.
|
|
|
|
Опрос статуса работает, но заставляет принимающую сторону дёргать сервис
|
|
каждые несколько секунд. Вебхук снимает это: сервис сам постучится, когда
|
|
задача готова.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
__all__ = ["sign_payload", "deliver", "deliver_async", "secret_fingerprint"]
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Задержки между попытками: сразу, через полминуты, через пять минут.
|
|
# Дольше ждать нет смысла - результат лежит в очереди и его можно забрать.
|
|
RETRY_DELAYS = (0, 30, 300)
|
|
TIMEOUT_SEC = 30
|
|
|
|
|
|
def sign_payload(body: bytes, secret: str) -> str:
|
|
"""Подпись тела запроса, чтобы принимающая сторона знала, что это мы."""
|
|
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
def deliver(url: str, payload: dict, secret: str = "",
|
|
delays: tuple = RETRY_DELAYS) -> bool:
|
|
"""Отправляет результат, повторяя при неудаче. Возвращает признак успеха."""
|
|
import requests
|
|
|
|
# ensure_ascii=False: кириллица уходит живыми UTF-8 байтами. Принимающая
|
|
# сторона обязана считать подпись от сырого тела запроса - если она
|
|
# пересоберёт JSON из разобранного объекта, байты изменятся и подпись
|
|
# не сойдётся ни при каком секрете.
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
signature = sign_payload(body, secret) if secret else ""
|
|
if signature:
|
|
headers["X-Talkscore-Signature"] = signature
|
|
|
|
# Ни подпись, ни хеш тела секретом не являются: по ним ничего не
|
|
# восстанавливается, зато обе стороны могут сверить, об одном ли теле речь.
|
|
log.info("вебхук по задаче %s: тело %d байт, sha256 %s, подпись %s",
|
|
payload.get("job_id"), len(body),
|
|
hashlib.sha256(body).hexdigest()[:12],
|
|
signature[:16] or "НЕ ПОДПИСАНО (секрет не задан)")
|
|
|
|
for attempt, delay in enumerate(delays, start=1):
|
|
if delay:
|
|
time.sleep(delay)
|
|
try:
|
|
response = requests.post(url, data=body, headers=headers, timeout=TIMEOUT_SEC)
|
|
if response.status_code < 300:
|
|
log.info("вебхук доставлен по задаче %s", payload.get("job_id"))
|
|
return True
|
|
log.warning("вебхук: попытка %d, ответ %s %s", attempt,
|
|
response.status_code, response.text[:200])
|
|
except Exception as exc: # noqa: BLE001 - причина неважна, важна повторная попытка
|
|
log.warning("вебхук: попытка %d не удалась (%s)", attempt, exc)
|
|
log.error("вебхук не доставлен по задаче %s, результат остаётся в очереди",
|
|
payload.get("job_id"))
|
|
return False
|
|
|
|
|
|
def deliver_async(url: str, payload: dict, secret: str = "") -> None:
|
|
"""Отправляет в фоне: воркер не должен ждать чужой сервер."""
|
|
thread = threading.Thread(target=deliver, args=(url, payload, secret),
|
|
name="webhook", daemon=True)
|
|
thread.start()
|
|
|
|
|
|
def secret_fingerprint(secret: str) -> str:
|
|
"""Короткий отпечаток секрета для сверки настроек между сторонами.
|
|
|
|
Значение пересылать нельзя - оно осядет в переписке и журналах. Отпечаток
|
|
отвечает на единственный нужный вопрос: одинаковый ли секрет настроен
|
|
здесь и там. Восстановить по нему секрет нельзя.
|
|
"""
|
|
if not secret:
|
|
return ""
|
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:8]
|