SMS: сериализация отправки между кампаниями под аккаунтный лимит sms.ru (8 SMS за 10 минут суммарно)

Ограничение sms.ru — не более 8 SMS за 10 минут ПО АККУНТУ суммарно, на любые
номера (антибомбинг; превышение → отказ с кодами 231/232). Таймеры first/second/
paid стартуют в одну и ту же секунду, поэтому счётчик нужно читать
последовательно, иначе каждый процесс видит «свободно 8» и вместе они
превышают лимит.

- sms_rate_limit.py: acquire_send_lock/release_send_lock на pg_advisory_lock,
  lock_timeout 120с; докстринг, описание настроек и текст алерта приведены к
  правилу «8 суммарно по аккаунту» (страховка «N на номер» остаётся).
- first/second/paid: блокировка берётся сразу после получения списка групп и
  снимается перед выходом; если блокировку взять не удалось — прогон
  пропускается (лучше пропуск, чем блокировка аккаунта), следующая попытка по
  таймеру.
- Настройки: sms_limit_account_10min=8 (основное правило), окно 600с;
  sms_limit_per_number_10min=8 остаётся второй страховкой.

Проверено на клоне боевой базы (sms_rl_test), реальный sms.ru не вызывался:
- 2 процесса × 20 попыток одновременно → ровно 8 отправок (4+4), далее
  «аккаунт: уже 8 SMS за 600с (лимит 8)»; в БД 8 строк с sms_id в окне;
- блокировка: держатель 6с → попытка с таймаутом 2с = False (2.0с), с
  таймаутом 20с = True (2.7с);
- E2E сц.3 (боевой профиль per_number=0/account=8/win=600): 8 ушло, 3
  придержано; сц.1 (per-number=8) — регресс пройден.
This commit is contained in:
hermes 2026-10-04 13:20:07 +00:00
parent f55b43e716
commit d9327f320c
4 changed files with 111 additions and 14 deletions

View File

@ -35,10 +35,14 @@ import psycopg2
from psycopg2.extras import RealDictCursor
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
from sms_rate_limit import check_send_allowed, alert_due
from sms_rate_limit import (
check_send_allowed, alert_due, acquire_send_lock, release_send_lock,
)
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
check_send_allowed = None
alert_due = None
acquire_send_lock = None
release_send_lock = None
# ─── Конфигурация ────────────────────────────────────────────────────────────
@ -430,6 +434,14 @@ def step_send_new(conn, settings):
groups = get_groups_to_send(conn, max_attempts)
log.info(f"Step 1: {len(groups)} groups to send SMS")
# --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний ---
_send_lock = False
if acquire_send_lock is not None:
_send_lock = acquire_send_lock(conn)
if not _send_lock:
log.warning("Прогон пропущен: не удалось взять блокировку отправки")
return 0
sent_count = 0
limited_count = 0
limited_last = None
@ -546,7 +558,8 @@ def step_send_new(conn, settings):
_acct = (limited_info or {}).get("account")
msg = (
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
f"(окно {_win}с, лимит аккаунта {_acct} суммарно, "
f"страховка {_cap} на номер); "
f"причина: {limited_last}; "
f"сообщения не потеряны — уйдут в следующий запуск."
)
@ -556,6 +569,9 @@ def step_send_new(conn, settings):
else:
log.info("Rate limit Telegram alert suppressed (throttled)")
if _send_lock and release_send_lock is not None:
release_send_lock(conn)
return sent_count
def step_check_status(conn, settings):

View File

@ -28,10 +28,14 @@ import psycopg2
from psycopg2.extras import RealDictCursor
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
from sms_rate_limit import check_send_allowed, alert_due
from sms_rate_limit import (
check_send_allowed, alert_due, acquire_send_lock, release_send_lock,
)
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
check_send_allowed = None
alert_due = None
acquire_send_lock = None
release_send_lock = None
# ─── Конфигурация ────────────────────────────────────────────────────────────
@ -369,6 +373,14 @@ def step_send_new(conn, settings):
groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
# --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний ---
_send_lock = False
if acquire_send_lock is not None:
_send_lock = acquire_send_lock(conn)
if not _send_lock:
log.warning("Прогон пропущен: не удалось взять блокировку отправки")
return 0
sent_count = 0
limited_count = 0
limited_last = None
@ -452,7 +464,8 @@ def step_send_new(conn, settings):
_acct = (limited_info or {}).get("account")
msg = (
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
f"(окно {_win}с, лимит аккаунта {_acct} суммарно, "
f"страховка {_cap} на номер); "
f"причина: {limited_last}; "
f"сообщения не потеряны — уйдут в следующий запуск."
)
@ -462,6 +475,9 @@ def step_send_new(conn, settings):
else:
log.info("Rate limit Telegram alert suppressed (throttled)")
if _send_lock and release_send_lock is not None:
release_send_lock(conn)
return sent_count
def step_check_status(conn, settings):

View File

@ -1,18 +1,23 @@
#!/usr/bin/env python3
"""Проактивный лимитер отправки SMS для SuperSam (правило sms.ru).
sms.ru блокирует номер/аккаунт, если на ОДИН номер уходит слишком много SMS
(«бомбинг»): по умолчанию **не более 8 сообщений на номер за 10 минут**.
Сейчас кампании узнают об этом только постфактум — по коду ошибки 231/232,
после чего группа помечается send_failed, а часть сообщений уже потеряна.
Этот модуль проверяет лимит ДО отправки и придерживает сообщение: оно не
теряется, а уходит в следующий запуск кампании.
Главное ограничение sms.ru — **не более 8 SMS за 10 минут по аккаунту
суммарно, на любые номера** (антибомбинг; при превышении отправка отклоняется
кодами 231/232). Отдельно страхуемся правилом «не более N SMS на один номер
за окно». Сейчас кампании узнают о лимите только постфактум — по коду ошибки
231/232, после чего группа помечается send_failed, а часть сообщений уже
потеряна. Этот модуль проверяет лимит ДО отправки и придерживает сообщение:
оно не теряется, а уходит в следующий запуск кампании.
Отправку всех кампаний сериализует общая блокировка (acquire_send_lock):
аккаунтный счётчик один на всех, а таймеры кампаний стартуют в одну секунду.
Правило и окно настраиваются в sms_campaign_settings (админка/БД):
sms_limit_per_number_10min — сколько SMS на один номер за окно
(по умолчанию 8; 0 = проверка выключена)
sms_limit_account_10min — сколько SMS всего за окно
(по умолчанию 0 = выключено)
sms_limit_account_10min — сколько SMS всего за окно, по аккаунту
(боевое значение 8 = правило sms.ru;
0 = проверка выключена)
sms_rate_window_seconds — окно в секундах (по умолчанию 600)
Что считается отправкой: строки sms_campaign_log, реально принятые sms.ru
@ -141,3 +146,47 @@ def alert_due(conn, settings, throttle_seconds=ALERT_THROTTLE_SECONDS):
conn.rollback()
log.error("alert_due failed: %s", exc)
return False
# ─── Блокировка отправки (сериализация кампаний) ─────────────────────────────
SEND_LOCK_KEY = 0x53535331 # 'SSS1' — общий ключ отправки всех SMS-кампаний
LOCK_TIMEOUT_SECONDS = 120 # ждём чужой прогон не дольше двух минут
def acquire_send_lock(conn, timeout_seconds=LOCK_TIMEOUT_SECONDS):
"""Захватить общую блокировку отправки (pg_advisory_lock).
Аккаунтный лимит sms.ru общий для всех кампаний, а их таймеры стартуют в
одну и ту же секунду: без сериализации каждый процесс видит «свободно 8» и
вместе они отправят больше лимита. Блокировка — на соединении, снимается
сама при закрытии коннекта.
True — блокировка взята, можно отправлять.
False — чужой прогон не завершился за timeout_seconds: прогон пропускаем
(лучше задержка, чем блокировка аккаунта у sms.ru), повторим по
таймеру.
"""
try:
with conn.cursor() as cur:
cur.execute("SET lock_timeout = %s", (f"{int(timeout_seconds)}s",))
cur.execute("SELECT pg_advisory_lock(%s)", (SEND_LOCK_KEY,))
return True
except Exception as e: # noqa: BLE001 — недоступная блокировка не должна ломать прогон
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
log.error("Не удалось взять блокировку отправки (%s) — прогон пропущен", e)
return False
def release_send_lock(conn):
"""Снять блокировку отправки. Ошибки не критичны: снимет закрытие коннекта."""
try:
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_unlock(%s)", (SEND_LOCK_KEY,))
return True
except Exception as e: # noqa: BLE001
log.warning("Не удалось снять блокировку отправки: %s", e)
return False

View File

@ -36,10 +36,14 @@ import psycopg2
from psycopg2.extras import RealDictCursor
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
from sms_rate_limit import check_send_allowed, alert_due
from sms_rate_limit import (
check_send_allowed, alert_due, acquire_send_lock, release_send_lock,
)
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
check_send_allowed = None
alert_due = None
acquire_send_lock = None
release_send_lock = None
# ─── Конфигурация ────────────────────────────────────────────────────────────
@ -444,6 +448,14 @@ def step_send_new(conn, settings):
groups = get_groups_to_send(conn, max_attempts)
log.info(f"Step 1: {len(groups)} groups to send second SMS")
# --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний ---
_send_lock = False
if acquire_send_lock is not None:
_send_lock = acquire_send_lock(conn)
if not _send_lock:
log.warning("Прогон пропущен: не удалось взять блокировку отправки")
return 0
sent_count = 0
limited_count = 0
limited_last = None
@ -535,7 +547,8 @@ def step_send_new(conn, settings):
_acct = (limited_info or {}).get("account")
msg = (
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
f"(окно {_win}с, лимит аккаунта {_acct} суммарно, "
f"страховка {_cap} на номер); "
f"причина: {limited_last}; "
f"сообщения не потеряны — уйдут в следующий запуск."
)
@ -545,6 +558,9 @@ def step_send_new(conn, settings):
else:
log.info("Rate limit Telegram alert suppressed (throttled)")
if _send_lock and release_send_lock is not None:
release_send_lock(conn)
return sent_count
def step_check_status(conn, settings):