В любом API рано или поздно встают три практические задачи: защита от слишком частых запросов, работа с файлами и постепенный вывод старых версий. Разберём каждую с нуля.
Rate limiting: что происходит, когда клиент слишком часто стучится
Представьте, что у вас открытое API и кто-то посылает тысячу запросов в секунду — намеренно или по ошибке. Без защиты ваш сервер либо начнёт тормозить для всех, либо упадёт.
Rate limiting — это ограничение числа запросов за период времени. Когда клиент превышает лимит, сервер отвечает кодом 429 Too Many Requests.
Правило хорошего тона: к ответу 429 всегда нужно добавлять заголовок Retry-After, чтобы клиент знал, сколько подождать перед следующей попыткой. Без этого клиент не понимает, что делать дальше.
В Go стандартным инструментом служит пакет golang.org/x/time/rate. Он реализует алгоритм «маркерного ведра» (token bucket): в ведре есть N маркеров, каждый запрос тратит один маркер, маркеры пополняются с заданной частотой.
Глобальный лимит через middleware
import (
"math"
"golang.org/x/time/rate"
)
func RateLimitMiddleware(limiter *rate.Limiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remaining := int(math.Floor(limiter.Tokens())) - 1
if remaining < 0 {
remaining = 0
}
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
w.Header().Set("RateLimit-Limit", strconv.Itoa(limiter.Burst()))
w.Header().Set("RateLimit-Remaining", "0")
w.Header().Set("RateLimit-Reset", "1") // секунд до пополнения, не unix-время
writeProblem(w, http.StatusTooManyRequests,
"RATE_LIMIT_EXCEEDED", "Too Many Requests",
"Превышен лимит запросов, повторите через 1 секунду",
traceIDFromCtx(r.Context()))
return
}
// сообщаем клиенту, сколько запросов у него осталось
w.Header().Set("RateLimit-Limit", strconv.Itoa(limiter.Burst()))
w.Header().Set("RateLimit-Remaining", strconv.Itoa(remaining))
next.ServeHTTP(w, r)
})
}
}
// подключение: 100 запросов в секунду для всех, ведро на 100 маркеров
globalLimiter := rate.NewLimiter(rate.Limit(100), 100)
r := chi.NewRouter()
r.Use(RateLimitMiddleware(globalLimiter))
Три заголовка RateLimit-* в ответе — удобный сигнал для клиентов: они видят, сколько запросов у них ещё есть, и могут сами замедлиться до того, как получат 429.
Осторожно с конструктором: первый аргумент — частота пополнения, второй — размер ведра. rate.Every(time.Second) — это один маркер в секунду, а не сто; rate.NewLimiter(rate.Every(time.Second), 100) пропустит сто запросов залпом, а дальше по одному в секунду.
Лимит на пользователя
Глобальный лимит не справедлив: один активный пользователь может израсходовать весь бюджет, и остальные пострадают. Решение — хранить отдельный Limiter для каждого пользователя:
var limiters sync.Map
func perUserLimiter(userID string) *rate.Limiter {
l, _ := limiters.LoadOrStore(userID, rate.NewLimiter(rate.Every(time.Minute), 60))
return l.(*rate.Limiter)
}
sync.Map безопасен для конкурентного доступа из разных горутин. Две оговорки: rate.Every(time.Minute) с ведром 60 — это один маркер в минуту, то есть 60 запросов залпом и дальше по одному; для «равномерно 60 в минуту» берут rate.Limit(1) с ведром поменьше. И карта без вытеснения растёт с каждым новым пользователем — лимитеры, к которым давно не обращались, удаляют по таймеру. Для крупных систем хранилище лимитеров выносят в Redis — тогда ограничение работает и при нескольких экземплярах сервиса.
Альтернатива: лимит на уровне шлюза
Часто проще настроить rate limiting в API-шлюзе (nginx, Envoy, Kong) — тогда в коде сервиса ничего не нужно. Выбор зависит от того, нужна ли логика лимитирования внутри бизнес-кода или достаточно инфраструктурного решения.
Загрузка файлов: multipart/form-data
Когда нужно передать файл в API, стандартный формат — multipart/form-data. Это не JSON: тело запроса разбивается на несколько частей, каждая со своим именем и содержимым.
Приём файла
r.Post("/api/v1/products/{id}/images", uploadProductImage)
func uploadProductImage(w http.ResponseWriter, r *http.Request) {
productID := chi.URLParam(r, "id")
// ограничиваем размер тела: сверх 32 МБ чтение оборвётся с ошибкой
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
// 8 МБ держим в памяти, остальное multipart сложит во временный файл
if err := r.ParseMultipartForm(8 << 20); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid multipart form or file too large"))
return
}
file, header, err := r.FormFile("file")
if err != nil {
httperr.Write(w, r, apperr.NewValidation("missing file field"))
return
}
defer file.Close()
// читаем первые 512 байт для определения типа файла
buf := make([]byte, 512)
n, err := io.ReadFull(file, buf)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
httperr.Write(w, r, apperr.NewValidation("cannot read file"))
return
}
contentType := http.DetectContentType(buf[:n])
allowed := map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
if !allowed[contentType] {
httperr.Write(w, r, apperr.NewValidation(
"unsupported file type: allowed image/jpeg, image/png, image/webp"))
return
}
// возвращаем курсор в начало перед сохранением
file.Seek(0, io.SeekStart)
meta, err := storage.Save(r.Context(), productID, file, header.Filename, contentType)
if err != nil {
httperr.Write(w, r, err)
return
}
w.Header().Set("Location", "/api/v1/products/"+productID+"/images/"+meta.ID)
writeJSON(w, http.StatusCreated, toImageResponse(meta))
}
Важная деталь: тип файла определяется через http.DetectContentType, который смотрит на содержимое файла. Проверять по расширению в имени файла (if ext == ".jpg") — частая ошибка: расширение легко подделать.
После чтения первых байтов позиция сдвинулась, поэтому перед сохранением её возвращают в начало через file.Seek(0, io.SeekStart). io.ReadFull вместо file.Read нужен потому, что Read может вернуть меньше запрошенного, а файл короче 512 байт даст ErrUnexpectedEOF — это не ошибка, детектору хватит того, что есть.
Аргумент ParseMultipartForm — не лимит, а порог памяти: части больше него уходят во временные файлы, а запрос принимается любого размера. Лимит задаёт http.MaxBytesReader до разбора; превышение приходит из ParseMultipartForm ошибкой *http.MaxBytesError, и её честно переводят в 413.
Ответ после загрузки
При успешной загрузке возвращаем 201 Created с метаданными файла:
type ImageResponse struct {
ImageID string `json:"imageId"`
ProductID string `json:"productId"`
URL string `json:"url"`
ContentType string `json:"contentType"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"createdAt"`
}
Ответ без метаданных (просто 201 с пустым телом) неудобен: клиент не знает, какой идентификатор получил файл и по какому URL его можно скачать.
Скачивание файла
r.Get("/api/v1/products/{id}/images/{imageId}", downloadProductImage)
func downloadProductImage(w http.ResponseWriter, r *http.Request) {
productID := chi.URLParam(r, "id")
imageID := chi.URLParam(r, "imageId")
content, meta, err := storage.Load(r.Context(), productID, imageID)
if err != nil {
httperr.Write(w, r, err)
return
}
w.Header().Set("Content-Type", meta.ContentType)
w.Header().Set("Content-Disposition",
mime.FormatMediaType("attachment", map[string]string{"filename": meta.Filename}))
http.ServeContent(w, r, meta.Filename, meta.UpdatedAt, bytes.NewReader(content))
}
http.ServeContent берёт на себя поддержку диапазонных запросов (Range), условных запросов (If-Modified-Since, If-Range) и выставление правильного Content-Length. Использовать его предпочтительнее, чем вручную копировать байты через io.Copy.
Заголовок Content-Disposition: attachment даёт браузеру сигнал скачать файл, а не открывать его внутри вкладки. Имя файла склеивают не строкой, а через mime.FormatMediaType: кавычка или кириллица в имени иначе ломают заголовок, а функция сама закодирует их по RFC 2231 (filename*=utf-8''...). И bytes.NewReader(content) годится для картинок, но не для гигабайтных выгрузок — там хранилище должно отдавать io.ReadSeeker, чтобы файл не ложился в память целиком.
Deprecation: как правильно выводить API из эксплуатации
Когда вы выпускаете v2 API и хотите отключить v1, нельзя просто удалить старые эндпоинты — часть клиентов ещё их использует. Правильный подход: сначала объявить версию устаревшей, дать клиентам время мигрировать, потом отключить.
Для этого есть стандартные HTTP-заголовки:
Deprecation— дата, с которой эндпоинт считается устаревшим (RFC 9745:@и unix-время; значениеtrueосталось от черновика стандарта).Sunset— дата, после которой эндпоинт перестанет работать (формат RFC 7231).Link— ссылка на версию-преемника.
Middleware для устаревшего маршрута
func deprecatedMiddleware(sunsetDate, successorURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Sunset", sunsetDate)
w.Header().Set("Deprecation", "@1798761600") // 1 января 2027
w.Header().Set("Link", `<`+successorURL+`>; rel="successor-version"`)
next.ServeHTTP(w, r)
})
}
}
Применение — навешиваем на весь /api/v1 после того, как /api/v2 стал доступен:
r.Route("/api/v1", func(r chi.Router) {
r.Use(deprecatedMiddleware(
"Sat, 01 Jan 2027 00:00:00 GMT",
"https://api.example.com/api/v2",
))
r.Route("/orders", ordersRouterV1)
r.Route("/products", productsRouterV1)
})
r.Route("/api/v2", func(r chi.Router) {
r.Route("/orders", ordersRouterV2)
r.Route("/products", productsRouterV2)
})
Каждый ответ от /api/v1 будет содержать эти заголовки — клиент (или его SDK) увидит их в логах и сможет запланировать миграцию.
Пример ответа от устаревшего эндпоинта
HTTP/1.1 200 OK
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Deprecation: @1798761600
Link: <https://api.example.com/api/v2>; rel="successor-version"
Content-Type: application/json
{"orderId": "a3f2d1", "status": "NEW", ...}
Обратите внимание: эндпоинт продолжает отвечать 200 — он ещё работает, просто предупреждает о будущем отключении.
После даты Sunset — 410 Gone
Когда дата наступила, эндпоинт нужно не просто удалить, а явно ответить 410 Gone. Пустой 404 вводит клиентов в заблуждение — они думают, что адрес неправильный, а не что API намеренно отключено.
func sunsetHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusGone,
"ENDPOINT_REMOVED", "Gone",
"Этот endpoint удалён. Используйте /api/v2",
traceIDFromCtx(r.Context()))
}
}
// заменяем весь /api/v1 на обработчик 410
r.Mount("/api/v1", http.HandlerFunc(sunsetHandler()))
Объявлять API устаревшим в OpenAPI-спецификации (deprecated: true) без указания даты отключения бессмысленно: клиент не знает, когда что-то сломается.
Коротко
- Rate limiting отвечает
429 Too Many Requests. К429обязателенRetry-After— без него клиент не знает, когда повторить запрос. - Три заголовка
RateLimit-Limit,RateLimit-Remaining,RateLimit-Resetпомогают клиентам управлять своей нагрузкой самостоятельно. golang.org/x/time/rateреализует маркерное ведро: первый аргумент — частота пополнения (rate.Limit(100), а неrate.Every(time.Second)), второй — размер ведра; для per-user лимитов —sync.Mapс вытеснением или Redis.- Файлы передаются через
POST multipart/form-data; лимит на размер ставитhttp.MaxBytesReaderдо разбора, аргументParseMultipartForm— только порог памяти. - Тип файла определяется через
http.DetectContentTypeпо содержимому, а не по расширению в имени. http.ServeContentпри скачивании автоматически обрабатываетRangeи условные запросы.- Устаревший эндпоинт помечается заголовками
Deprecation(дата как@unix-времяпо RFC 9745),SunsetиLinkсо ссылкой на преемника. - После наступления даты Sunset эндпоинт отвечает
410 Gone, а не просто удаляется.
Что почитать дальше
- Заголовки в Go REST API —
Idempotency-Key,traceparentи другие. - Версионирование API в Go — когда и как вводить v2, параллельная поддержка.
- Ошибки RFC 9457 в Go —
410 Gone,429как Problem Details. - Batch, async, локализация в Go — асинхронные и длительные операции.