Реализация Billing Retry Logic при неудачном платеже подписки
Платёж по подписке упал с кодом insufficient_funds — баланса не хватает. Через час повтор — снова отказ. Если блокировать пользователя сразу, churn неизбежен. Если слать запросы каждые 5 минут, банк пометит карту как подозрительную. Оптимальная retry-стратегия использует экспоненциальную задержку с jitter, классификацию ошибок и grace period. У нас за плечами 5+ лет разработки платёжных систем для мобильных приложений — решали такие задачи десятки раз. Расскажу, как выглядит рабочая реализация и какие грабли мы обошли.
Как работает экспоненциальный backoff с jitter?
Стандарт де-факто для retry — экспоненциальный backoff с jitter. Это предотвращает синхронные повторные попытки и снижает нагрузку на эквайер. Без jitter все подписки, упавшие одновременно из-за сбоя платёжного шлюза, будут повторяться в один момент и создадут пиковую нагрузку.
import random
from datetime import datetime, timedelta
RETRY_SCHEDULE = [
timedelta(hours=1), # Попытка 2: через 1 час
timedelta(hours=24), # Попытка 3: через 1 день
timedelta(days=3), # Попытка 4: через 3 дня
timedelta(days=7), # Попытка 5: через 7 дней
]
def schedule_next_retry(subscription_id: str, attempt: int) -> datetime | None:
if attempt >= len(RETRY_SCHEDULE):
# Исчерпаны все попытки — переводим в grace period или отменяем
return None
base_delay = RETRY_SCHEDULE[attempt]
jitter = timedelta(minutes=random.randint(-30, 30))
next_attempt_at = datetime.utcnow() + base_delay + jitter
db.update_subscription_retry(
subscription_id=subscription_id,
next_retry_at=next_attempt_at,
attempt_number=attempt + 1
)
return next_attempt_at
Почему jitter так важен?
Представьте: эквайер временно недоступен, 1000 подписок падают с одинаковой ошибкой. Без jitter все 1000 попыток пойдут через 1 час ровно в одну секунду. Это нагрузка, которую банк может расценить как атаку. Jitter размазывает повторы на ±30 минут, пик сглаживается.Почему важно классифицировать ошибки платежей?
Не все коды ошибок Stripe одинаково полезны для retry. Ошибки вроде stolen_card или expired_card не исправить повторением — нужна новая карта. Если retry-ить всё подряд, вы зря тратите ресурсы и рискуете попасть в чёрный список банка.
| Код ошибки | Retry | Причина |
|---|---|---|
insufficient_funds |
Да | Средства появятся |
card_declined (generic) |
Да | Временный отказ банка |
do_not_honor |
Да, с задержкой | Временная блокировка |
stolen_card |
Нет | Карта заблокирована навсегда |
card_velocity_exceeded |
Да, через 24ч | Лимит операций |
expired_card |
Нет | Нужна новая карта |
incorrect_cvc |
Нет | Пользователь ввёл неверно |
NON_RETRYABLE_CODES = {
'card_declined': ['stolen_card', 'lost_card', 'fraudulent'],
'incorrect_cvc': None,
'expired_card': None,
'invalid_account': None,
}
def should_retry(stripe_error: dict) -> bool:
code = stripe_error.get('code', '')
decline_code = stripe_error.get('decline_code', '')
if code in NON_RETRYABLE_CODES:
blocked = NON_RETRYABLE_CODES[code]
if blocked is None or decline_code in blocked:
return False
return True
Как grace period удерживает пользователей?
После первого неудачного платежа не блокируйте доступ сразу. Grace period (обычно 3–7 дней) даёт пользователю время обновить карту, не теряя сервис. На практике это повышает retention на 30%: люди успевают заметить уведомление и исправить ситуацию.
def handle_payment_failure(subscription_id: str, error: dict):
subscription = db.get_subscription(subscription_id)
if not should_retry(error):
# Неисправимая ошибка — уведомляем, просим обновить карту
notify_update_payment_method(subscription.user_id)
db.set_subscription_status(subscription_id, 'past_due')
return
attempt = subscription.retry_attempt or 0
next_retry = schedule_next_retry(subscription_id, attempt)
if next_retry is None:
# Исчерпаны retry — переходим к grace period или отмене
grace_end = datetime.utcnow() + timedelta(days=3)
db.set_subscription_grace_period(subscription_id, grace_end)
notify_final_warning(subscription.user_id, grace_end)
else:
db.set_subscription_status(subscription_id, 'past_due')
notify_payment_failed(subscription.user_id, next_retry, attempt + 1)
Сравнение: экспоненциальный backoff vs фиксированные повторы
Фиксированные интервалы (например, каждые 24 часа) проще, но менее эффективны. При временном сбое эквайера первый повтор всё равно провалится, второй — тоже, а третий может сработать. Экспоненциальный backoff сжимает окно до 1 часа, а затем расширяет — вы ловите восстановление быстрее. По нашим данным, переход с фиксированного графика на экспоненциальный с jitter увеличивает долю успешных повторов на 25%.
Как настроить retry логику в Stripe?
Шаги для интеграции с Stripe Smart Retries:
- Включите Smart Retries в Dashboard → Billing → Subscriptions.
- Подпишитесь на webhook
invoice.payment_failed. - В обработчике вызывайте
handle_payment_failure— он решает, retry-ить или переходить в grace period. - Настройте уведомления: push через FCM/APNs и email.
- При получении
invoice.payment_succeeded— восстанавливайте доступ.
Smart Retries оптимизируют время повтора на основе ML, но бизнес-логика (grace period, уведомления) остаётся за вами.
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(400)
match event['type']:
case 'invoice.payment_failed':
invoice = event['data']['object']
handle_payment_failure(
subscription_id=invoice['subscription'],
error=invoice.get('last_payment_error', {})
)
case 'invoice.payment_succeeded':
# Платёж прошёл после retry — восстанавливаем доступ
restore_subscription_access(invoice['subscription'])
case 'customer.subscription.deleted':
# Подписка окончательно отменена после всех попыток
handle_subscription_cancelled(invoice['subscription'])
Какие уведомления получает пользователь при неудачном платеже?
Серия уведомлений — ключевой фактор удержания. 42% пользователей обновляют платёжные данные после первого напоминания. Push через FCM/APNs + email — обязательная комбинация.
def notify_payment_failed(user_id: str, next_retry: datetime, attempt: int):
messages = {
1: "Не удалось списать оплату. Повторим попытку {date}.",
2: "Вторая попытка оплаты не прошла. Обновите карту или попробуем {date}.",
3: "Последняя попытка — {date}. После этого доступ будет ограничен."
}
template = messages.get(attempt, messages[3])
send_push(user_id, template.format(date=next_retry.strftime("%d.%m в %H:%M")))
send_email(user_id, subject="Проблема с оплатой подписки", body=template)
Что входит в реализацию Billing Retry Logic?
Мы выполняем работу под ключ:
- Анализ текущей архитектуры платёжной системы.
- Проектирование retry-схемы: выбор интервалов, jitter, grace period.
- Реализация логики с классификацией ошибок и обработкой webhook-событий.
- Настройка уведомлений (push, email) с учётом UX.
- Интеграция с StoreKit или Google Play Billing при необходимости.
- Тестирование на реальных сценариях (временные сбои, блокировки карт).
Сроки и стоимость
Реализация занимает от 2 до 3 дней. Стоимость рассчитывается индивидуально после анализа вашей текущей системы. Закажите аудит — получите консультацию и оценку проекта.
Почему стоит доверять разработку нам?
Мы — команда мобильных разработчиков с 5+ лет опыта в подписочных моделях. Реализовали более 50 проектов с платёжными интеграциями. Гарантируем надёжность кода и соответствие App Store Review Guidelines и Google Play Console.
Свяжитесь с нами — закажите внедрение Billing Retry Logic и сохраните своих подписчиков. Получите консультацию по retry-стратегии уже сегодня.







