Вы когда-нибудь задумывались, сколько раз и с какой периодичностью нужно пытаться списать деньги за подписку, если не удалось списать с первого раза? Вот я тоже об этом никогда не думал — до того момента, пока самому не пришлось писать эту логику для своего сервиса.
Задача звучала почти неприлично просто: «если автосписание не прошло, попробуй ещё раз через какое-то время». Казалось бы, одна строчка кода. А внутри — комментарий-заглушка: «ретрай через 1, 2, 4, 8… X, где X вычислить исходя из длины периода подписки». И вот тут началось самое интересное.
Первая ловушка: «просто сделай экспоненциальный backoff»
Первая мысль, которая приходит в голову любому, кто писал retry-логику для внешних API — экспоненциальный backoff. Час, два, четыре, восемь часов между попытками. Работает для HTTP-запросов, почему не должно работать для платежей?
Потому что у неудачного списания подписки причины принципиально другие, чем у сетевого таймаута. Если сравнить их между собой:
- Технический сбой — таймаут на стороне эквайринга, кратковременная недоступность платёжного шлюза. Решается сам за минуты, максимум за пару часов.
- Финансовая причина — на счету просто нет денег. А это, на секундочку, самая частая причина неудачных автосписаний подписок вообще. И решается она не за часы, а к конкретным датам — зарплате, авансу, очередному пополнению счёта.
Если гонять экспоненту в часах, к третьей-четвёртой попытке вы всё ещё будете в масштабе «через 8 часов», хотя человеку, возможно, эти деньги придут на карту только через неделю. Значит, чистая экспонента в часах — не то. Нужна шкала, которая на поздних попытках переходит в дни.
Вторая ловушка: диапазон подписок — от часа до трёх лет
Дальше выяснилась деталь, которая на корню ломает любое решение с «магическими константами». В системе минимальная подписка может длиться 1 час, а максимальная — 3 года. Формулировка «повторить через 3 дня» звучит разумно для месячной подписки и откровенно абсурдно для часовой: пользователь давно забыл, что вообще что-то покупал, а система всё ещё пытается с него списать.
Значит, единственная работающая идея — это не абсолютные величины, а пропорция от длины периода подписки. Условно: окно всех повторных попыток = какая-то доля от периода. Чем длиннее подписка, тем больше смысла побороться за клиента и подождать; чем короче — тем меньше.
Но и тут есть подвох. Чистая пропорция даёт вырожденные случаи на краях диапазона:
- Для часовой подписки 20% от периода — это 6 минут на все попытки. Слишком мало: реальному человеку нужно хотя бы сутки, чтобы среагировать и пополнить счёт.
- Для трёхлетней подписки 20% от периода — это больше полугода. А удерживать «зависшую» неоплаченную подписку такой срок бизнесу попросту невыгодно.
Решение: пропорция, зажатая между двумя жёсткими границами
В итоге логика сложилась в три содержательных бизнес-величины вместо одной:
- Floor (нижняя граница) — минимальное окно на все попытки, даже для самой короткой подписки. У нас это сутки: даже для часовой подписки компания готова ждать оплату целый день.
- Cap (верхняя граница) — жёсткий потолок, дольше которого бизнес не готов ждать оплату ни при каких условиях. У нас это 3 недели.
- Proportional fraction — доля от длины периода (у нас 20%), которая работает в «средней зоне», когда пропорция не упирается ни в пол, ни в потолок.
Такая формула даёт ровно то поведение, которое нужно:
| Период подписки | Окно ретраев |
|---|---|
| 1 час — 3 дня | упирается в floor → 1 сутки |
| неделя — ~3 месяца | растёт пропорционально периоду |
| полгода и больше | упирается в cap → 21 день |
Никаких развилок «если период меньше месяца — то так, а если больше — то эдак» — одна формула естественно ведёт себя на всём диапазоне от часа до трёх лет.
Третья деталь: первая попытка — особенная
Когда я уже было решил, что все N попыток нужно просто разложить экспоненциально внутри окна, всплыл ещё один нюанс. Первая повторная попытка вообще не должна зависеть ни от какой пропорции — это чисто техническая проверка «а вдруг был кратковременный сбой сети». Она должна происходить быстро и одинаково для любой подписки: что для часовой, что для трёхлетней — через 5–10 минут.
Поэтому расписание попыток в итоге выглядит так:
- Попытка 1 — фиксированная задержка (~7 минут), никак не зависит от периода подписки.
- Попытки 2…N — распределяются геометрически (каждый следующий интервал вдвое больше предыдущего) уже не по всему окну, а по оставшейся его части, так, чтобы последняя попытка пришлась точно на конец окна.
На выходе получается вот такая картина для подписок разного масштаба (5 попыток, floor = 24 часа, cap = 21 день, доля = 20%):
| Период | Окно | Попытка 1 | Попытка 2 | Попытка 3 | Попытка 4 | Попытка 5 |
|---|---|---|---|---|---|---|
| 1 час | 1 сутки | 7 мин | 1ч 42м | 4ч 54м | 11ч 16м | 24ч |
| 1 месяц | 6 дней | 7 мин | 9ч 43м | 1д 4ч 54м | 2д 19ч 16м | 6 дней |
| 1 год | 21 день | 7 мин | 1д 9ч 43м | 4д 4ч 54м | 9д 19ч 16м | 21 день |
Первая попытка везде одинаковая — ловит технические сбои. Дальше кривая растягивается ровно настолько, насколько позволяет масштаб подписки.
От бизнес-логики к коду
Когда модель устоялась, переносить её в код оказалось приятно скучным занятием — вся сложность уже была продумана на уровне идеи, оставалось только формализовать. Пять содержательных параметров (максимальное число попыток, задержка первой попытки, floor, cap, доля) легли в конфигурацию, а расчёт следующей даты — в компактный утильный класс, не зависящий ни от фреймворка, ни от доменных сущностей.
Что я вынес для себя из этой задачи
- Не спешить решать «в лоб». Первый инстинкт — экспоненциальный backoff в часах — работает для сетевых ретраев, но плохо подходит для платежей, где главная причина неудачи не техническая, а финансовая.
- Искать не константы, а инварианты. Как только выяснилось, что диапазон периодов подписки — от часа до трёх лет, стало ясно: любая абсолютная константа обречена работать хорошо только для одного масштаба. Нужна была пропорция.
- У пропорций тоже есть края. Чистый процент от периода даёт абсурдные значения на границах диапазона — отсюда floor и cap.
- Разделять техническое и бизнесовое. Первая попытка ловит сбои инфраструктуры и не имеет отношения к финансовой логике клиента — поэтому она живёт по своим правилам, отдельно от остальных.
Итоговая формула уместилась в пять параметров и один нехитрый алгоритм. Но чтобы дойти до этих пяти чисел, пришлось несколько раз развернуться на сто восемьдесят градусов — что, пожалуй, и есть самая честная иллюстрация того, что «простая» задача про повторные попытки списания на деле оказывается вопросом продуктового мышления куда больше, чем вопросом кода.
