1. Rate limiting: навіщо він потрібен
Якщо вам здається, що rate limiting — це турбота сервера, ви мислите як оптиміст. А мережевий код має мислити як людина, яка одного разу натиснула «Запустити» й випадково надіслала 300 запитів за секунду, бо в неї був цикл for і трохи зайвої впевненості в собі.
Rate limiting на клієнті потрібен не для краси:
- знижує ймовірність отримати 429 Too Many Requests;
- зменшує навантаження на API;
- робить поведінку застосунку більш вихованою та передбачуваною.
Уявімо ситуацію в нашому навчальному CLI LibraryCLI: є команда fetch (або подібна), яка звертається до зовнішнього API за даними книги. Користувач може викликати її кілька разів поспіль, може написати скрипт, а може випадково запустити команду в циклі. Без обмежень ви перетворюєтеся на маленький домашній DDoS — тільки без злого наміру. І саме такі випадкові сценарії трапляються найчастіше.
Rate limiting — це правило на кшталт: «не частіше, ніж раз на N секунд». Воно просте, але корисне, як звичка зберігати файл перед тим, як IDE раптом вирішить перезавантажитися.
Ментальна модель: «шлагбаум» перед мережею
Лімітер зручно уявляти як шлагбаум на виїзді з вашого застосунку в інтернет. Машини (запити) можуть їхати, але не можуть їхати впритул, бампер у бампер: між ними має бути мінімальний інтервал. Якщо наступна машина підʼїхала занадто рано — вона просто зачекає.
sequenceDiagram
participant App as LibraryCLI
participant Lim as RateLimiter
participant Net as HTTPClient (реальна мережа)
App->>Lim: send(request)
Lim->>Lim: перевірка lastRequestAt
alt минуло достатньо часу
Lim->>Net: надіслати одразу
else занадто рано
Lim->>Lim: Task.sleep(remaining)
Lim->>Net: надіслати після очікування
end
Важливо: лімітер не «прискорює інтернет». Він просто привчає нас до дисципліни. Це як будильник: він не робить ранок приємним, він робить ранок неминучим.
Не плутаємо: rate limiting, backoff і cache
Дуже типова плутанина: «А хіба backoff уже не про очікування? Навіщо ще й лімітер?» Схожі механізми, але сенс у них різний. Зручно порівняти три політики однією таблицею.
| Політика | Коли застосовується | Що робить | Навіщо |
|---|---|---|---|
| Cache | До мережі | Іноді взагалі не йдемо в мережу | Зменшує кількість запитів |
| Rate limiting | Перед кожним реальним запитом | Обмежує частоту запитів | Не «спамимо» API, менше 429 |
| Backoff | Після помилки й перед повтором | Робить паузи, що щоразу подовжуються, між спробами | Дає шанс, що проблема мине сама, і не підсилює аварію |
Cache — це «не роби зайвого». Rate limiting — «роби, але не надто часто». Backoff — «якщо все зламалося, не бийтеся чолом у двері кожні 0.001 секунди».
Ще одна важлива річ: rate limiting зазвичай діє навіть тоді, коли все добре. Backoff — це реакція на проблему. Тобто лімітер — дисципліна, а backoff — обережність після удару об стіну.
Чому лімітер зберігає стан
Якби лімітер був «чистою функцією», він би не знав, що було раніше. Але йому потрібно памʼятати, коли ми надсилали останній запит. Отже, він зберігає стан: наприклад lastRequestAt.
І тут починається найцікавіше: будь-який компонент зі станом стає джерелом потенційних багів. У цій версії ми фіксуємо важливе обмеження: один екземпляр лімітера передбачає послідовні виклики (single-writer). Тобто ми не проєктуємо його так, щоб два паралельні Task одночасно зверталися до одного й того самого лімітера, і він при цьому працював коректно.
Бо якщо два паралельні виклики одночасно подивляться на lastRequestAt, обидва побачать «можна» — і обидва підуть у мережу. А ви такі: «Ну я ж обмежував!» Обмежували, але без ізоляції спільного стану це перетворюється на «сподівалися на краще».
Механізми ізоляції спільного стану ми поки що не розглядаємо — це окрема велика тема. Тому далі дотримуємося простого правила: якщо компонент зберігає змінний стан, використовуємо його послідовно.
2. Реалізуємо простий RateLimiter
Найзрозуміліша реалізація лімітера будується навколо двох речей:
- задаємо мінімальний інтервал між запитами, наприклад 0.2 секунди (тобто не частіше 5 запитів за секунду);
- зберігаємо момент часу, коли був надісланий попередній запит.
Якщо новий запит приходить раніше, ніж через minInterval, ми чекаємо.
Ключовий момент для async-коду: «чекати» не означає блокувати потік. Жодних sleep(1) з C. Ми призупиняємо задачу через Task.sleep, щоб інші задачі могли виконуватися, а застосунок не перетворювався на «я чекаю — значить, увесь світ теж чекає».
Базовий RateLimiter
import Foundation
struct RateLimiter {
let minInterval: TimeInterval
private var lastRequestAt: Date?
mutating func waitIfNeeded() async throws {
let now = Date()
if let last = lastRequestAt {
let elapsed = now.timeIntervalSince(last)
let remaining = minInterval - elapsed
if remaining > 0 {
let ns = UInt64((remaining * 1_000_000_000).rounded())
try await Task.sleep(nanoseconds: ns)
}
}
try Task.checkCancellation()
lastRequestAt = Date()
}
}
Тут варто придивитися до кількох речей.
Ми беремо let now = Date(), обчислюємо, скільки часу минуло від попереднього запиту, і якщо цього часу ще замало — чекаємо решту. Після очікування ми перевіряємо скасування через Task.checkCancellation(). Це важливо: якщо задачу вже скасували, наприклад користувач перервав команду, ми не повинні продовжувати, ніби нічого не сталося.
І зверніть увагу: ми оновлюємо lastRequestAt після очікування. Це логіка «час останнього дозволеного запиту», а не «час останньої спроби».
Утиліта: секунди → наносекунди
Іноді зручніше винести конвертацію окремо, щоб менше ризикувати помилитися через зайвий нуль.
import Foundation
func toNanoseconds(_ seconds: TimeInterval) -> UInt64 {
let safe = max(0, seconds)
return UInt64((safe * 1_000_000_000).rounded())
}
Це не обовʼязковий код, але він зменшує ризик помилки на кшталт «я думав, що це мілісекунди».
3. Вбудовуємо лімітер у HTTPClient
Тепер найважливіше: куди це вбудувати, щоб лімітер справді працював, а не лежав у каталозі «утиліти» як талісман.
Ми вже працювали з ідеєю HTTPClient як транспорту: у нього є метод send(_:), який повертає (Data, HTTPURLResponse) або кидає помилку. Отже, лімітер логічно розмістити навколо транспорту: зробити обгортку, яка перед надсиланням за потреби чекає.
Контракт HTTPClient
import Foundation
protocol HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
Обгортка RateLimitedHTTPClient
import Foundation
final class RateLimitedHTTPClient: HTTPClient {
private var limiter: RateLimiter
private let base: HTTPClient
init(base: HTTPClient, minInterval: TimeInterval) {
self.base = base
self.limiter = RateLimiter(minInterval: minInterval)
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
try await limiter.waitIfNeeded()
return try await base.send(request)
}
}
limiter тут — var, бо змінюється його стан. І це ще раз підкреслює: перед нами компонент зі станом. А отже, згадуємо обмеження: один екземпляр цього клієнта — лише для послідовних викликів. Якщо ви почнете викликати send паралельно з двох задач, можете отримати «подвійний проїзд під шлагбаумом».
Правильний порядок: cache перед лімітером
Це один із найпідступніших моментів, тому що код може бути «логічно правильним», але UX буде дивним.
Якщо лімітер стоїть раніше за cache, то навіть коли дані вже є в памʼяті, ви все одно чекатимете. Це схоже на ситуацію, коли ви стоїте в черзі в кафе, щоб… подивитися на меню, яке у вас уже відкрите в телефоні.
Правильна ідея: спочатку спробуйте cache, і тільки якщо cache не спрацював — застосовуйте лімітер і йдіть у мережу.
Композиційно це означає: cache має бути зовнішньою обгорткою, а лімітер — усередині, ближче до реальної мережі.
flowchart LR
A[ApiClient] --> B[CachingHTTPClient]
B --> C[RateLimitedHTTPClient]
C --> D[URLSessionHTTPClient]
Тобто CachingHTTPClient на cache-hit взагалі не викликає base.send, а отже лімітер не бере участі.
Псевдокомпозиція без деталей реалізації кешу, бо її ми вже розбирали в попередній лекції:
// Схема складання конвеєра: cache -> limiter -> base
func makeHTTPPipeline(base: HTTPClient) -> HTTPClient {
let limited = RateLimitedHTTPClient(base: base, minInterval: 0.2)
let cached = CachingHTTPClient(base: limited, ttl: 10) // ttl у секундах
return cached
}
Тут важлива не конкретна реалізація, а сама думка: порядок політик — частина дизайну.
Мінідемо без реальної мережі
Мережевий код підступний тим, що ви можете не помітити, що лімітер працює. Тому іноді корисно зробити «фейковий HTTPClient», який просто виводить час, коли його викликали.
import Foundation
struct PrintHTTPClient: HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
print("Відправлення о \(Date())") // наприклад: 2026-01-16 12:00:00 +0000
let response = HTTPURLResponse(url: request.url!, statusCode: 200,
httpVersion: nil, headerFields: nil)!
return (Data(), response)
}
}
import Foundation
func demoRateLimiting() async throws {
let base = PrintHTTPClient()
let client = RateLimitedHTTPClient(base: base, minInterval: 1.0)
let url = URL(string: "https://example.com")!
let req = URLRequest(url: url)
_ = try await client.send(req)
_ = try await client.send(req)
_ = try await client.send(req)
}
Якщо все працює, між рядками виводу буде приблизно секунда. Не мілісекунда і не «здається», а справжня пауза. І це добрий знак: лімітер — не «оптимізація», а дисципліна.
Де зберігати цю політику
Коли ви проєктуєте шар мережі, завжди виникає питання: «де саме зберігати ці політики?» Якщо розмазати їх по коду, вийде «павутина з if-ів», яку ніхто не любить, окрім багів. Баги люблять павутину — там затишно.
Добре місце для лімітера — там само, де живе надсилання запитів, тобто поруч із транспортом. Тоді ApiClient може зосередитися на інтерпретації відповіді, декодуванні DTO та мапінгу помилок, а лімітер гарантуватиме частоту для будь-якого запиту, який реально йде в мережу.
Ще одна перевага такого рішення: якщо вище в стеку є retry-логіка, то кожна retry-спроба, яка справді піде в мережу, автоматично пройде через лімітер. І ви не отримаєте ситуацію «у мене є лімітер, але ретраї його обходять».
4. Обмеження: лише послідовні виклики
Наша реалізація — мінімальна й навчальна. Вона спеціально розрахована лише на послідовні виклики, тому що ми поки не вводили механізми безпечної ізоляції спільного змінного стану.
Якщо коротко, проблема виглядає так: lastRequestAt — спільний змінний шматок памʼяті. Якщо два паралельні виклики send надійшли одночасно, вони можуть обидва вирішити, що чекати не треба, і обидва надішлють запит. У кращому разі лімітер не спрацює. У гіршому — ви отримаєте важкі для відлову гонки, коли іноді чекає, а іноді — ні.
Тому правило тут спокійне й без героїзму: один екземпляр лімітера / rate-limited клієнта використовуємо лише послідовно. Якщо вам потрібно паралелити мережеві запити, це окреме архітектурне питання, яке ми зараз не розвʼязуємо.
5. Типові помилки під час додавання rate limiting
Помилка № 1: плутати rate limiting і backoff та очікувати, що лімітер «лікує помилки».
Лімітер не лікує ні 500, ні таймаут, ні поганий Wi‑Fi. Він лише регулює частоту запитів. Якщо мережа впала, ви будете красиво й дисципліновано помилятися, але все одно помилятися. Тимчасові збої має закривати retry/backoff, а лімітер — зменшувати шанс «самому створити проблему частотою».
Помилка № 2: поставити лімітер «до кешу» й дивуватися, що на cache-hit усе одно є затримка.
Якщо лімітер стоїть зовнішньою обгорткою, він спрацьовує навіть тоді, коли мережа не потрібна. Це дає дивний UX: «чому команда думає секунду, якщо дані вже в памʼяті?» Правильніше будувати пайплайн так, щоб cache перехоплював запит раніше, а лімітер вмикався тільки на cache-miss.
Помилка № 3: створити кілька екземплярів лімітера й випадково вимкнути обмеження.
Rate limiting працює лише якщо стан спільний для всіх запитів, які ви хочете обмежити. Якщо ви створюєте новий RateLimitedHTTPClient на кожен запит, lastRequestAt щоразу буде nil, і лімітер пропускатиме все одразу. Це одна з найобразливіших помилок, бо код «виглядає правильно», а логіка забуває про минуле.
Помилка № 4: не поважати скасування й продовжувати чекати або надсилати запит, хоча він уже не потрібен.
Якщо ви робите Task.sleep, але не перевіряєте скасування, ви можете витратити час на очікування і навіть надіслати запит, хоча користувач уже скасував операцію. В async-коді скасування — це частина нормального керування виконанням, а не «рідкісний випадок».
Помилка № 5: намагатися використовувати один і той самий лімітер із паралельних задач без домовленостей.
Наша версія лімітера передбачає послідовні виклики. Якщо ви почнете звертатися до одного екземпляра одночасно з кількох Task, отримаєте гонки й дивну поведінку «то чекає, то не чекає». Поки ми не обговорювали ізоляцію спільного стану, це потрібно прийняти як обмеження дизайну, а не як «випадкову дрібницю».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ