Представьте: вы на вечеринке, пытаетесь взять ещё кусочек пиццы. Первая попытка не удаётся, потому что кто-то утащил последнюю пепперони. Вы сдаётесь? Нет! Вы проверяете ещё раз через 30 секунд. Всё ещё нет пиццы? Подождите минутку. Проверьте ещё раз. Это логика повторных попыток в самом аппетитном виде — и сегодня мы превратим вас в Гордона Рамзи среди устойчивых распределённых систем.

Когда жизнь даёт вам HTTP 500…

Давайте начнём с правды: распределённые системы похожи на мои последние отношения — они выйдут из строя, когда вы меньше всего этого ожидаете. Но, в отличие от моего бывшего, мы действительно можем решить эти проблемы с помощью умных стратегий повторных попыток.

Три золотых правила повторных попыток:

  1. Никогда не повторяйте попытки при неустранимых ошибках (например, HTTP 404 — этой пиццы больше нет).
  2. Всегда ограничивайте свои попытки (никто не любит сталкеров).
  3. Добавляйте случайность к своим повторным попыткам (избегайте синхронизированных давлений толпы).

Вот как я реализую это на Python — с дополнительным остроумием:

import time
import random
from dataclasses import dataclass
@dataclass
class RetryConfig:
    max_attempts: int = 5
    base_delay: float = 0.3  # Начинаем с 300 мс
    jitter: float = 0.5      # Добавляем до 50% случайности
def retriable(func, config: RetryConfig = RetryConfig()):
    def wrapper(*args, **kwargs):
        for attempt in range(1, config.max_attempts + 1):
            try:
                return func(*args, **kwargs)
            except TransientError as e:
                if attempt == config.max_attempts:
                    raise RetryExhaustedError(f"Не удалось после {попытки} попыток") из e
                backoff = config.base_delay * (2 ** попытка)
                jittered = backoff * (1 + random.uniform(-config.jitter, config.jitter))
                time.sleep(jittered)
                print(f"Попытка {попытка} не удалась. Вздремну {jittered:.2f} сек. 💤")
        return None
    return wrapper

Этот код сочетает экспоненциальную отсрочку с добавлением случайности — это как давать вашей системе порции эспрессо, которые становятся всё сильнее, но со случайными брызгами молока, чтобы избежать предсказуемости.

Танец распределённых систем

Давайте визуализируем этот танец повторных попыток с помощью диаграммы последовательности:

sequenceDiagram participant Клиент participant Сервис как Ненадёжный сервис Клиент->>Сервис: GET /api/pizza Сервис-->>Клиент: 503 Сервис недоступен loop Повторная попытка с отсрочкой и добавлением случайности Клиент->>Клиент: Рассчитать отсрочку Клиент->>Клиент: Добавить случайное добавление Клиент->>Сервис: GET /api/pizza Сервис-->>Клиент: 503 Сервис недоступен end Сервис-->>Клиент: 200 ОК (Пепперони Парадайз!)

Обратите внимание, как каждая повторная попытка увеличивает время ожидания, добавляя случайность? Это предотвращает эффект «давки повторных попыток», когда тысячи клиентов одновременно бомбардируют ваш и без того перегруженный сервис.

Автоматические выключатели: консультант по отношениям

Иногда вам нужно перестать пытаться и дать системе передышку. Введите автоматические выключатели — Мари Кондо распределённых систем:

class Автоматический выключатель:
    def __init__(self, threshold=5, reset_timeout=60):
        self.failure_count = 0
        self.threshold = threshold
        self.reset_timeout = reset_timeout
        self.state = "ЗАКРЫТО"
    def выполнить(self, операцию):
        если self.state == "ОТКРЫТО":
            поднять ошибку CircuitOpenError("Нет. На это я больше не куплюсь!")
        попробуйте:
            результат = операция()
            self._reset()
            вернуть результат
        кроме исключения:
            self.failure_count += 1
            если self.failure_count >= self.threshold:
                self._trip()
            поднять
    def _trip(self):
        self.state = "ОТКРЫТО"
        threading.Timer(self.reset_timeout, self._reset).start()
    def _reset(self):
        self.state = "ЗАКРЫТО"
        self.failure_count = 0

Объедините это с нашей логикой повторных попыток, и у вас получится система, которая знает, когда двигаться вперёд, а когда сделать перерыв — как приложение для осознанной медитации для ваших микросервисов.

Истории из окопов

В прошлом году я случайно устроил DDoS-атаку на наш собственный сервис аутентификации, забыв о двух важных моментах:

  1. Бюджет повторных попыток: ограничение количества повторных попыток за минуту для всех сервисов.
  2. Распространение крайнего срока: обеспечение соблюдения тайм-аутов вышестоящими сервисами. Результат? Наши средства мониторинга выглядели как график цен на биткоины во время бычьего роста. Извлечённый урок: всегда сочетайте повторные попытки с:
@retriable(config=RetryConfig(max_attempts=3))
def выполнить_запрос_с_тайм-аутом():
    вернуть запросы.получить(url, тайм-аут=(3,05, 27)) # Да, эти конкретные числа имеют значение

Императив идемпотентности

Прежде чем сойти с ума от повторных попыток, помните: повторные попытки неидемпотентных операций похожи на переигрывание неловких школьных лет — вы получите те же неприятные результаты. Всегда спрашивайте:

  1. Можно ли эту операцию безопасно повторить?
  2. Используем ли мы ключи идемпотентности?
  3. Протестировали ли мы сценарии сбоя… дважды?

Заключительная мудрость (и шутки от папы)

Реализация повторных попыток похожа на приготовление гуакамоле — всё дело в правильных ингредиентах в правильных пропорциях:

  • 2 чашки экспоненциальной отсрочки;
  • 1 столовая ложка джиттера;
  • Щепотка прерывания цепи;
  • Немного мониторинга (Prometheus необязателен, но рекомендуется). Помните: хорошая стратегия повторных попыток подобна хорошей шутке — всё зависит от времени. Если у вас всё получится, ваши системы будут смеяться всю дорогу до банка (надёжности пять девяток). Теперь идите вперёд и повторяйте попытки ответственно! А если ничего не помогает… может быть, попробовать эту аналогию с вечеринкой с пиццей ещё раз? 🍕