← назад к разделу

В любом 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 — асинхронные и длительные операции.