JavaRush /Курси /Go SELF /Простий rate limit: мінімальна in-memory реалізація

Простий rate limit: мінімальна in-memory реалізація

Go SELF
Рівень 62 , Лекція 3
Відкрита

1. Навіщо потрібен rate limit

Якщо простіше, rate limit — це турнікет на вході до вашого API. Не для того, щоб «мучити» чесного користувача, а щоб один надто активний клієнт або бот не перетворив ваш сервер на сумний гарбуз, який тільки й уміє падати, логувати помилки та жалкувати про вибір професії.

У світі HTTP rate limit зазвичай відповідає на запитання на кшталт: «Скільки запитів за хвилину ми готові обслужити від одного клієнта?» і «Що робимо, якщо клієнт переборщив?». На друге запитання є стандартна відповідь: HTTP 429 Too Many Requests. Важливо, що 429 — це не «внутрішня помилка сервера», а очікувана політика. Це як табличка «вхід за квитками»: ніхто не дивується, що без квитка не пускають.

Ще один мотив: rate limit допомагає зробити систему передбачуванішою. Навіть якщо у вас усе працює швидко, раптовий сплеск запитів — наприклад, коли хтось запустив скрипт у циклі, — може зіпсувати затримки для всіх. rate limit зменшує ймовірність таких «свят життя».

Fixed window: проста модель і параметри

Словосполучення «rate limiting» звучить так, ніби зараз буде дисертація на 150 сторінок, графіки, формули та професор, який підозріло любить слово «токен». Але тут беремо найпростішу, але цілком практичну модель: fixed window (фіксоване вікно).

Ідея проста. Ви обираєте:

window — тривалість вікна, наприклад 1 minute
limit  — скільки запитів дозволено за це вікно, наприклад 30

Далі для кожного клієнта ви зберігаєте два числа: скільки запитів він уже зробив у поточному вікні та коли це вікно закінчується. Щойно вікно спливає — лічильник скидається, і починається нове вікно.

Коротка таблиця, щоб не плутатися:

Параметр Приклад Зміст
window
1 minute
«період вимірювання»
limit
30
«скільки запитів можна за цей період»
count
0…limit
«скільки вже використано»
until
time.Time
«коли вікно закінчиться»

Так, fixed window не ідеальний: на межі вікна можна втиснути багато запитів. Але він чудово підходить, щоб зрозуміти принцип, перевірити реалізацію й не потонути в деталях.

Хто такий «клієнт» і як вибрати ключ для ліміту

Rate limit майже завжди роблять окремо для кожного клієнта. Отже, нам потрібно зрозуміти, що вважати «одним клієнтом».

В ідеальному світі у нас був би стабільний ідентифікатор клієнта: user id з auth, API key, токен — щось стійке. Але в межах навчального сервера зробимо просту й зрозумілу річ: візьмемо r.RemoteAddr.

RemoteAddr — це рядок вигляду IP:port, наприклад 203.0.113.10:54321. Це не ідеально: порт змінюється, бувають проксі та балансувальники. Зате підхід чесно показує механіку: є вхідний запит, у нього є «адреса», і ми рахуємо за нею.

Щоб було охайніше, відокремимо IP від порту. Це корисно, щоб один і той самий клієнт не виглядав як «сто різних клієнтів» лише через новий порт.

import (
	"net"
	"net/http"
)

func clientKey(r *http.Request) string {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

Якщо SplitHostPort не зміг розібрати адресу — залишаємо її як є. У навчальному коді це нормальна стратегія: не ускладнюємо життя.

2. Паралельні запити: навіщо потрібен sync.Mutex

Момент, який часто ставить новачків у глухий кут, — і це нормально: ви можете придумати ідеальну логіку rate limit на папері, але в реальному HTTP‑сервері вона почне поводитися дивно, якщо забути про одну деталь — сервер обробляє запити паралельно.

У net/http кожен запит може оброблятися у своїй goroutine. Отже, два запити можуть одночасно зайти до вашого лімітера і одночасно спробувати оновити спільну структуру даних, зазвичай map.

А ось map у Go не любить конкурентний запис: це не просто «іноді неправильний результат», а цілком реальний сценарій із runtime panic. Тому нам потрібен захист спільного стану.

У нас буде спільний map, де за ключем клієнта лежить стан вікна. Щоб операції «прочитав → оновив → записав» були атомарними з погляду логіки, ми захистимо їх sync.Mutex.

Так, у Go є sync.Map, який безпечний для конкурентного доступу. Але для навчання та для простого лімітера sync.Mutex + map — простіша й зрозуміліша схема, а ще це базова навичка, яка знадобиться вам не раз. sync.Map корисно пам’ятати як спеціалізований інструмент для окремих сценаріїв.

Структури даних

Зробимо дві структури: одна — для «вікна», інша — для самого лімітера.

windowHits зберігає межу вікна та лічильник, а fixedWindowLimiter — налаштування, map і Mutex.

import "time"

type windowHits struct {
	until time.Time
	count int
}
import (
	"sync"
	"time"
)

type fixedWindowLimiter struct {
	window time.Duration
	limit  int

	mu   sync.Mutex
	hits map[string]windowHits
}

Зверніть увагу: hits можна створити одразу через make, а можна відкласти — до першого запиту. Відкладена ініціалізація зручна, якщо лімітер створюється як &fixedWindowLimiter{...}.

Метод Allow: серце лімітера

Нам потрібна функція, яка вирішує: «пускати чи ні». Зазвичай її називають Allow.

Ключова думка: Allow має бути максимально передбачуваною. Їй передають ключ клієнта й поточний момент часу now, а вона повертає true/false.

Чому ми передаємо now, а не викликаємо time.Now() всередині? Щоб код легше було контролювати й перевіряти. Навіть якщо ви зараз не пишете тести, звичка виносити time.Now() назовні робить код охайнішим.

import "time"

func (l *fixedWindowLimiter) Allow(key string, now time.Time) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	if l.hits == nil {
		l.hits = make(map[string]windowHits)
	}

	h := l.hits[key]
	if now.After(h.until) {
		h = windowHits{until: now.Add(l.window), count: 0}
	}

	if h.count >= l.limit {
		l.hits[key] = h
		return false
	}

	h.count++
	l.hits[key] = h
	return true
}

Що тут важливо:

  • Одразу беріть Lock() і одразу ставте defer Unlock(). Це ваш «ремінь безпеки»: навіть якщо згодом з’явиться ранній return, Unlock() усе одно спрацює.
  • Ініціалізуємо map, якщо він nil.
  • Читаємо поточний стан h := l.hits[key]. Якщо ключа немає, отримаємо zero value (until = time.Time{}, count = 0) — для нас це нормально.
  • Якщо поточний час уже після завершення вікна, скидаємо його: виставляємо новий until і ставимо count = 0.
  • Якщо count >= limit, відмовляємо.
  • Інакше збільшуємо count і дозволяємо запит.

4. Middleware: перетворюємо лімітер на HTTP-поведінку

Middleware — це функція, яка приймає next http.Handler і повертає новий http.Handler. Усередині вона може виконати дію «до» або «після», а може взагалі не викликати next і завершити запит сама (short-circuit).

Rate limit — класичний short-circuit: якщо ліміт перевищено, ми одразу відповідаємо 429 і виходимо.

Припустімо, у нас уже є функція writeAPIError, яка записує error envelope у JSON, тобто єдиний формат помилок.

import (
	"net/http"
	"time"
)

func rateLimitMW(l *fixedWindowLimiter) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			key := clientKey(r)

			if !l.Allow(key, time.Now()) {
				writeAPIError(w, http.StatusTooManyRequests,
					"rate_limited", "rate limit exceeded")
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

Тут ми беремо ключ клієнта, викликаємо Allow і, якщо запит не проходить, відповідаємо 429.

Важливо, що ми відповідаємо за тим самим контрактом error envelope, що й решта помилок. Це робить API передбачуваним: клієнту не потрібно вгадувати, чи буде помилка текстом, HTML-сторінкою чи JSON.

Бонус: Retry-After без зайвої математики

Можна додати невеликий штрих, який покращує UX клієнта: заголовок Retry-After. Він підказує, через скільки секунд варто повторити запит.

Для fixed window це особливо просто: якщо ми знаємо until, можемо приблизно обчислити ceil(until-now).

Щоб не ускладнювати Allow, зробимо окремий метод RetryAfterSeconds, який читає поточне вікно під тим самим mutex.

import (
	"math"
	"time"
)

func (l *fixedWindowLimiter) RetryAfterSeconds(key string, now time.Time) int {
	l.mu.Lock()
	defer l.mu.Unlock()

	h := l.hits[key]
	if now.After(h.until) {
		return 0
	}

	sec := h.until.Sub(now).Seconds()
	return int(math.Ceil(sec))
}

І використаємо це в middleware:

ra := l.RetryAfterSeconds(key, time.Now())
w.Header().Set("Retry-After", fmt.Sprintf("%d", ra))
writeAPIError(w, http.StatusTooManyRequests, "rate_limited", "rate limit exceeded")

Якщо хочеться тримати все зовсім мінімальним — цей бонус можна не додавати. Але як концепт він корисний: сервер стає ввічливішим.

5. Вбудовування в застосунок і порядок middleware

Припустімо, у нас уже є:

  • mux з маршрутами /api/v1/tasks...
  • chain(...) для збирання middleware в ланцюжок
  • requestIDMW, logMW, authMW — написані раніше

Створімо лімітер під час старту й вставимо його в ланцюжок.

import (
	"net/http"
	"time"
)

func buildHandler(mux *http.ServeMux, auth Middleware) http.Handler {
	limiter := &fixedWindowLimiter{
		window: time.Minute,
		limit:  30,
	}

	return chain(mux,
		requestIDMW,
		logMW,
		auth,
		rateLimitMW(limiter),
	)
}

Порядок — це частина логіки, а не просто «краса».

Якщо поставити rate limit до auth, то лімітер обмежуватиме навіть тих, у кого взагалі немає ключа. Іноді це нормально, наприклад, щоб не завалювали 401 тисячами запитів. Але часто розумніше обмежувати вже авторизованих клієнтів.

Якщо у вас rate limit прив’язаний до API key (що зазвичай дуже розумно), то auth має йти раніше, щоб ви могли брати ключ клієнта з X-API-Key (а не з IP). Тут ми використовуємо RemoteAddr, тому це не критично — просто зафіксуємо здорову схему.

Невелика схема, щоб порядок відчувався наочно:

flowchart TD
    A[Запит надійшов] --> B[requestIDMW]
    B --> C[logMW — старт таймера]
    C --> D[authMW]
    D --> E[rateLimitMW]
    E --> F[обробник / mux]
    F --> C2[logMW — лог після next]

6. Обмеження in-memory підходу

In-memory rate limiter — це лімітер, який живе в пам’яті одного процесу.

Це означає, що якщо ви запустите два екземпляри сервера за балансувальником навантаження, ліміт працюватиме окремо на кожному. Клієнт зможе «обійти» ліміт, якщо його запити розподілятимуться між різними інстансами. Це не баг коду — це властивість обраного дизайну.

Ще один нюанс: hits з часом може зростати. Наприклад, якщо до вас прийде багато унікальних IP‑адрес або ви обрали ключ, який часто змінюється, map зберігатиме записи. У цій навчальній версії ми не робимо складного прибирання старих клієнтів. Максимум, що вже робимо, — повторно використовуємо запис і скидаємо вікно, коли воно спливло.

Якби ми хотіли зробити мінімальне прибирання без ускладнень, ми могли б видаляти запис, коли вікно спливає, і клієнт приходить знову. Тобто замість «reset» зробити «delete і створити заново». Але це не дає повного очищення: клієнт, який прийшов один раз і більше не повернувся, залишиться у map. Для навчальної реалізації це допустимо.

І ще один нюанс: так, можна спробувати sync/atomic для лічильників, але це швидко перетворюється на складнішу механіку. «Атомарний int» не рятує, бо вам усе одно потрібно узгоджено оновлювати кілька полів — і час вікна, і лічильник. Тому тут беремо простий і надійний Mutex.

7. Типові помилки під час реалізації rate limit

Помилка №1: змінювати map без Mutex («нібито працює»).
Це найчастіша й найпідступніша історія. Локально у вас може бути мало запитів, і все здається нормальним. А потім під навантаженням або просто за збігу в часі ви отримуєте гонку даних і потенційний runtime panic. В HTTP‑сервері запити паралельні за означенням, тому спільний map має бути захищений.

Помилка №2: взяти Lock(), а Unlock() забути на одній гілці.
Так народжуються новачківські взаємні блокування: сервер перестає відповідати, бо хтось тримає замок вічно. Найпростіший спосіб майже повністю виключити це — ставити defer l.mu.Unlock() одразу після Lock().

Помилка №3: не зробити short-circuit і викликати next після 429.
Іноді код пише 429, а потім за інерцією викликає next.ServeHTTP(w, r). У результаті нижній handler теж намагається писати відповідь, і ви отримуєте дивні ефекти: «заголовки вже надіслані», «подвійне тіло відповіді» та інші веселі речі. Якщо ліміт перевищено — відповіли й return.

Помилка №4: повертати 500 замість 429.
Rate limit — це не «ой, ми зламалися», а «ви надто часто». 500 означає «проблема на боці сервера», а 429 — «політика доступу або навантаження». Клієнт за 429 може коректно сповільнитися й повторити пізніше, а за 500 зазвичай починає думати, що сервер помер.

Помилка №5: обрати нестабільний ключ клієнта й дивуватися, що ліміт «не працює».
Якщо ключ постійно змінюється, наприклад, ви використовуєте RemoteAddr разом із портом, лімітер думатиме, що кожен запит — від нового клієнта, і перевищення ліміту не настане. Тому або стабілізуйте ключ, наприклад за IP, або використовуйте більш змістовний ідентифікатор (API key / user id), коли він у вас є.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ