Пакет notifier реализует асинхронную отправку уведомлений во внешний сервис
с ограничением скорости, повторными попытками и корректным завершением.
go test -race -v ./notifier/n := notifier.New(externalClient, notifier.Options{
Workers: 4,
RateLimit: 10,
})
err := n.Send(ctx, notifier.Message{ID: "1", Payload: "hello"})
stats := n.GetStats() // Sent, Failed, Retries
n.Close() // дожидается завершения всех задачSend() → [tasks chan] → worker 1 → rate limiter → ExternalClient.Post()
→ worker 2 → rate limiter → ExternalClient.Post()
→ worker N → rate limiter → ExternalClient.Post()
Сообщения попадают в буферизированный канал tasks. Пул воркеров
читает из канала, ожидает разрешения от rate limiter и выполняет отправку.
Я использовал стандартный подход в Go – фиксированный пул горутин, которые читают
задачи из общего канала. Канал tasks одновременно играет роль очереди
и механизма синхронизации: если очередь заполнена, Send() блокируется
и не даёт засыпать систему сообщениями.
Альтернатива: можно было бы запускать отдельную горутину на каждое сообщение, но при высокой нагрузке это приведёт к неконтролируемому росту горутин и усложнит ограничение скорости.
Отдельная горутина с time.Ticker добавляет токены в буферизированный канал
с частотой 1/N секунды, где N – лимит запросов в секунду. Воркер перед
отправкой забирает токен из канала – если токенов нет, он ждёт.
Почему не golang.org/x/time/rate? Я решил написать свою реализацию,
чтобы показать, как token bucket работает изнутри. Получилось около 30 строк.
При необходимости могу переделать на golang.org/x/time/rate.
Размер буфера канала токенов: я выбрал размер буфера равным N, чтобы при простое накопилось до N токенов и первая пачка запросов ушла без ожидания. Больше N токенов не накопится – лишние тики отбрасываются.
При получении HTTP 429 запрос повторяется до 3 раз. Задержка удваивается с каждой попыткой: 500ms → 1s → 2s. Это снижает нагрузку на внешний сервис, когда он перегружен.
Почему retry только на 429? Задание явно указывает на 429. Я думал о retry для 5xx, но не стал добавлять – повтор при серверной ошибке может как помочь, так и ухудшить ситуацию, и мне показалось, что для правильного решения не хватает контекста о поведении внешнего сервиса.
При вызове Close():
- Устанавливается флаг
closed– новыеSend()сразу получаютErrClosed. - Закрывается канал
done– все воркеры одновременно получают сигнал. - Каждый воркер обрабатывает оставшиеся задачи в канале
tasks(drain). Close()ждёт завершения всех воркеров черезsync.WaitGroup.
Почему done-канал, а не закрытие tasks? Если закрыть tasks из Close(),
а Send() в другой горутине попытается записать в него – будет паника.
Канал done безопасен: в него никто не пишет, его только закрывают.
Проверка closed через atomic.Bool: позволяет Send() быстро отсечь
вызовы после закрытия без блокировки. Дополнительно, select с done-каналом
страхует от гонки, если Close() вызвали между проверкой флага и записью в канал.
Для подсчёта метрик я выбрал атомарные счётчики – они проще и быстрее, чем мьютекс, для простого инкремента. Каждый воркер обновляет нужный счётчик после обработки сообщения.
GetStats() возвращает снимок: значения трёх счётчиков читаются по очереди,
поэтому снимок может на долю миллисекунды отставать от реальности.
Для метрик мониторинга это приемлемо.
notifier/
├── contract.go – публичные интерфейсы, типы, ошибки
├── limiter.go – rate limiter (token bucket)
├── notifier.go – реализация Notifier
└── notifier_test.go – тесты
| Сценарий | Тест |
|---|---|
| Успешная отправка | TestSendSuccess |
| Retry при 429 | TestRetryOn429 |
| Исчерпание попыток | TestMaxRetriesExhausted |
| Отправка после Close | TestSendAfterClose |
| Отмена контекста | TestContextCancellation |
| Graceful shutdown | TestGracefulShutdownProcessesRemaining |
| Конкурентная отправка | TestConcurrentSends |
| Rate limiting | TestRateLimiting |
| Ответ 5xx | TestNon2xxResponseFails |
| Ошибка клиента | TestClientReturnsError |
| Повторный Close | TestDoubleCloseIsIdempotent |
| Точность метрик | TestStatsAccuracy |
Все тесты проходят с флагом -race.