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:
parent
f55b43e716
commit
d9327f320c
|
|
@ -35,10 +35,14 @@ import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
|
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 — без лимитера кампания работает как раньше
|
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
|
||||||
check_send_allowed = None
|
check_send_allowed = None
|
||||||
alert_due = None
|
alert_due = None
|
||||||
|
acquire_send_lock = None
|
||||||
|
release_send_lock = None
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -429,6 +433,14 @@ def step_send_new(conn, settings):
|
||||||
|
|
||||||
groups = get_groups_to_send(conn, max_attempts)
|
groups = get_groups_to_send(conn, max_attempts)
|
||||||
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
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
|
sent_count = 0
|
||||||
limited_count = 0
|
limited_count = 0
|
||||||
|
|
@ -546,7 +558,8 @@ def step_send_new(conn, settings):
|
||||||
_acct = (limited_info or {}).get("account")
|
_acct = (limited_info or {}).get("account")
|
||||||
msg = (
|
msg = (
|
||||||
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
||||||
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
|
f"(окно {_win}с, лимит аккаунта {_acct} суммарно, "
|
||||||
|
f"страховка {_cap} на номер); "
|
||||||
f"причина: {limited_last}; "
|
f"причина: {limited_last}; "
|
||||||
f"сообщения не потеряны — уйдут в следующий запуск."
|
f"сообщения не потеряны — уйдут в следующий запуск."
|
||||||
)
|
)
|
||||||
|
|
@ -556,6 +569,9 @@ def step_send_new(conn, settings):
|
||||||
else:
|
else:
|
||||||
log.info("Rate limit Telegram alert suppressed (throttled)")
|
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
|
return sent_count
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
def step_check_status(conn, settings):
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,14 @@ import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
|
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 — без лимитера кампания работает как раньше
|
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
|
||||||
check_send_allowed = None
|
check_send_allowed = None
|
||||||
alert_due = 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)
|
groups = get_groups_to_send(conn)
|
||||||
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
|
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
|
sent_count = 0
|
||||||
limited_count = 0
|
limited_count = 0
|
||||||
limited_last = None
|
limited_last = None
|
||||||
|
|
@ -452,7 +464,8 @@ def step_send_new(conn, settings):
|
||||||
_acct = (limited_info or {}).get("account")
|
_acct = (limited_info or {}).get("account")
|
||||||
msg = (
|
msg = (
|
||||||
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
||||||
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
|
f"(окно {_win}с, лимит аккаунта {_acct} суммарно, "
|
||||||
|
f"страховка {_cap} на номер); "
|
||||||
f"причина: {limited_last}; "
|
f"причина: {limited_last}; "
|
||||||
f"сообщения не потеряны — уйдут в следующий запуск."
|
f"сообщения не потеряны — уйдут в следующий запуск."
|
||||||
)
|
)
|
||||||
|
|
@ -462,6 +475,9 @@ def step_send_new(conn, settings):
|
||||||
else:
|
else:
|
||||||
log.info("Rate limit Telegram alert suppressed (throttled)")
|
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
|
return sent_count
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
def step_check_status(conn, settings):
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Проактивный лимитер отправки SMS для SuperSam (правило sms.ru).
|
"""Проактивный лимитер отправки SMS для SuperSam (правило sms.ru).
|
||||||
|
|
||||||
sms.ru блокирует номер/аккаунт, если на ОДИН номер уходит слишком много SMS
|
Главное ограничение sms.ru — **не более 8 SMS за 10 минут по аккаунту
|
||||||
(«бомбинг»): по умолчанию **не более 8 сообщений на номер за 10 минут**.
|
суммарно, на любые номера** (антибомбинг; при превышении отправка отклоняется
|
||||||
Сейчас кампании узнают об этом только постфактум — по коду ошибки 231/232,
|
кодами 231/232). Отдельно страхуемся правилом «не более N SMS на один номер
|
||||||
после чего группа помечается send_failed, а часть сообщений уже потеряна.
|
за окно». Сейчас кампании узнают о лимите только постфактум — по коду ошибки
|
||||||
Этот модуль проверяет лимит ДО отправки и придерживает сообщение: оно не
|
231/232, после чего группа помечается send_failed, а часть сообщений уже
|
||||||
теряется, а уходит в следующий запуск кампании.
|
потеряна. Этот модуль проверяет лимит ДО отправки и придерживает сообщение:
|
||||||
|
оно не теряется, а уходит в следующий запуск кампании.
|
||||||
|
|
||||||
|
Отправку всех кампаний сериализует общая блокировка (acquire_send_lock):
|
||||||
|
аккаунтный счётчик один на всех, а таймеры кампаний стартуют в одну секунду.
|
||||||
|
|
||||||
Правило и окно настраиваются в sms_campaign_settings (админка/БД):
|
Правило и окно настраиваются в sms_campaign_settings (админка/БД):
|
||||||
sms_limit_per_number_10min — сколько SMS на один номер за окно
|
sms_limit_per_number_10min — сколько SMS на один номер за окно
|
||||||
(по умолчанию 8; 0 = проверка выключена)
|
(по умолчанию 8; 0 = проверка выключена)
|
||||||
sms_limit_account_10min — сколько SMS всего за окно
|
sms_limit_account_10min — сколько SMS всего за окно, по аккаунту
|
||||||
(по умолчанию 0 = выключено)
|
(боевое значение 8 = правило sms.ru;
|
||||||
|
0 = проверка выключена)
|
||||||
sms_rate_window_seconds — окно в секундах (по умолчанию 600)
|
sms_rate_window_seconds — окно в секундах (по умолчанию 600)
|
||||||
|
|
||||||
Что считается отправкой: строки sms_campaign_log, реально принятые sms.ru
|
Что считается отправкой: строки sms_campaign_log, реально принятые sms.ru
|
||||||
|
|
@ -141,3 +146,47 @@ def alert_due(conn, settings, throttle_seconds=ALERT_THROTTLE_SECONDS):
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
log.error("alert_due failed: %s", exc)
|
log.error("alert_due failed: %s", exc)
|
||||||
return False
|
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
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,14 @@ import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
|
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 — без лимитера кампания работает как раньше
|
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
|
||||||
check_send_allowed = None
|
check_send_allowed = None
|
||||||
alert_due = None
|
alert_due = None
|
||||||
|
acquire_send_lock = None
|
||||||
|
release_send_lock = None
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -443,6 +447,14 @@ def step_send_new(conn, settings):
|
||||||
|
|
||||||
groups = get_groups_to_send(conn, max_attempts)
|
groups = get_groups_to_send(conn, max_attempts)
|
||||||
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
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
|
sent_count = 0
|
||||||
limited_count = 0
|
limited_count = 0
|
||||||
|
|
@ -535,7 +547,8 @@ def step_send_new(conn, settings):
|
||||||
_acct = (limited_info or {}).get("account")
|
_acct = (limited_info or {}).get("account")
|
||||||
msg = (
|
msg = (
|
||||||
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
||||||
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
|
f"(окно {_win}с, лимит аккаунта {_acct} суммарно, "
|
||||||
|
f"страховка {_cap} на номер); "
|
||||||
f"причина: {limited_last}; "
|
f"причина: {limited_last}; "
|
||||||
f"сообщения не потеряны — уйдут в следующий запуск."
|
f"сообщения не потеряны — уйдут в следующий запуск."
|
||||||
)
|
)
|
||||||
|
|
@ -545,6 +558,9 @@ def step_send_new(conn, settings):
|
||||||
else:
|
else:
|
||||||
log.info("Rate limit Telegram alert suppressed (throttled)")
|
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
|
return sent_count
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
def step_check_status(conn, settings):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue