В любом 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(int(limiter.Limit())))
w.Header().Set("RateLimit-Remaining", "0")
w.Header().Set("RateLimit-Reset",
strconv.FormatInt(time.Now().Add(time.Second).Unix(), 10))
writeProblem(w, http.StatusTooManyRequests,
"RATE_LIMIT_EXCEEDED", "Too Many Requests",
"Превышен лимит запросов, повторите через 1 секунду",
traceIDFromCtx(r.Context()))
return
}
// сообщаем клиенту, сколько запросов у него осталось
w.Header().Set("RateLimit-Limit", strconv.Itoa(int(limiter.Limit())))
w.Header().Set("RateLimit-Remaining", strconv.Itoa(remaining))
next.ServeHTTP(w, r)
})
}
}
// подключение: 100 запросов в секунду для всех
globalLimiter := rate.NewLimiter(rate.Every(time.Second), 100)
r := chi.NewRouter()
r.Use(RateLimitMiddleware(globalLimiter))
Три заголовка RateLimit-* в ответе — удобный сигнал для клиентов: они видят, сколько запросов у них ещё есть, и могут сами замедлиться до того, как получат 429.
Лимит на пользователя
Глобальный лимит не справедлив: один активный пользователь может израсходовать весь бюджет, и остальные пострадают. Решение — хранить отдельный 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 безопасен для конкурентного доступа из разных горутин. Для крупных систем хранилище лимитеров выносят в 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 МБ; сверх него вернём ошибку
if err := r.ParseMultipartForm(32 << 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)
if _, err := file.Read(buf); err != nil {
httperr.Write(w, r, apperr.NewValidation("cannot read file"))
return
}
contentType := http.DetectContentType(buf)
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.Read(buf) позиция чтения сдвигается на 512 байт, поэтому перед сохранением нужно вернуть её в начало через file.Seek(0, io.SeekStart).
Ответ после загрузки
При успешной загрузке возвращаем 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",
`attachment; 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 даёт браузеру сигнал скачать файл, а не открывать его внутри вкладки.
Deprecation: как правильно выводить API из эксплуатации
Когда вы выпускаете v2 API и хотите отключить v1, нельзя просто удалить старые эндпоинты — часть клиентов ещё их использует. Правильный подход: сначала объявить версию устаревшей, дать клиентам время мигрировать, потом отключить.
Для этого есть стандартные HTTP-заголовки:
Deprecation: 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", "true")
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: true
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реализует маркерное ведро; для per-user лимитов —sync.Mapили Redis.- Файлы передаются через
POST multipart/form-data; лимит на размер устанавливается вParseMultipartForm(N). - Тип файла определяется через
http.DetectContentTypeпо содержимому, а не по расширению в имени. http.ServeContentпри скачивании автоматически обрабатываетRangeи условные запросы.- Устаревший эндпоинт помечается заголовками
Deprecation: true,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 — асинхронные и длительные операции.