Опирается на правила: R-RATE-1..3, R-RATE-X1, R-FILE-1..5, R-DEP-1..3, R-DEP-X1раздел Rate limiting.

Важно знать

  • Rate limiting429 Too Many Requests + Retry-After + RateLimit-* заголовки.
  • 429 без Retry-After — запрещён; клиент не знает когда повторить.
  • ФайлыPOST с multipart/form-data; лимит через ParseMultipartForm(N).
  • Тип файла — определяется через http.DetectContentType, не расширение.
  • СкачиваниеGET с Content-Disposition: attachment через http.ServeContent.
  • Deprecation — middleware добавляет Sunset, Deprecation: true, Link к каждому ответу.
  • deprecated: true в OpenAPI без Sunset — запрещён.

Rate limiting

Middleware на основе golang.org/x/time/rate

R-RATE-1..2:

import "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) {
            if !limiter.Allow() {
                retryAfter := time.Now().Add(time.Second)
                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(retryAfter.Unix(), 10))
                writeProblem(w, http.StatusTooManyRequests,
                    "RATE_LIMIT_EXCEEDED", "Too Many Requests",
                    "Превышен лимит запросов, повторите через 1 секунду",
                    traceIDFromCtx(r.Context()))
                return
            }
            // информируем об остатке лимита в успешных ответах (R-RATE-2)
            remaining := int(limiter.Tokens())
            w.Header().Set("RateLimit-Limit", strconv.Itoa(int(limiter.Limit())))
            w.Header().Set("RateLimit-Remaining", strconv.Itoa(remaining))
            next.ServeHTTP(w, r)
        })
    }
}

// применение
globalLimiter := rate.NewLimiter(rate.Every(time.Second), 100)

r := chi.NewRouter()
r.Use(RateLimitMiddleware(globalLimiter))

Для per-user лимитов — хранилище sync.Map или Redis:

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)
}

Загрузка файлов

POST multipart/form-data

R-FILE-1..4:

r.Post("/api/v1/products/{id}/images", uploadProductImage)

func uploadProductImage(w http.ResponseWriter, r *http.Request) {
    productID := chi.URLParam(r, "id")

    // 32 MB лимит (R-FILE-3: указан в документации)
    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()

    // определяем MIME по содержимому, не расширению
    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))
}

Response после загрузки:

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"`
}

Скачивание файла

R-FILE-5:

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.

Deprecation

Middleware

R-DEP-1..3:

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) {
            // RFC 8594 Sunset header
            w.Header().Set("Sunset", sunsetDate)
            w.Header().Set("Deprecation", "true")
            w.Header().Set("Link", `<`+successorURL+`>; rel="successor-version"`)
            next.ServeHTTP(w, r)
        })
    }
}

Применение к v1 после выхода 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)
})

Пример HTTP-ответа для устаревшего эндпоинта

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", ...}

После даты Sunset — 410 Gone

R-DEP-3 шаг 4:

func sunsetHandler(sunsetDate string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        writeProblem(w, http.StatusGone,
            "ENDPOINT_REMOVED", "Gone",
            "Этот endpoint удалён. Используйте /api/v2",
            traceIDFromCtx(r.Context()))
    }
}

// после наступления даты заменяем роутер:
r.Mount("/api/v1", http.HandlerFunc(sunsetHandler("01 Jan 2027")))

Что запрещено

АнтипаттернПравилоЧто взамен
429 без Retry-AfterR-RATE-X1Retry-After обязателен
429 без RateLimit-*R-RATE-X1RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
Тип файла по расширению (if ext == ".jpg")R-FILE-3http.DetectContentType
Ответ 201 без метаданных файлаR-FILE-4тело с imageId, url, size, contentType
deprecated: true в OpenAPI без SunsetR-DEP-X1Sunset + дата отключения
Удаление эндпоинта без 410 Gone после SunsetR-DEP-3сначала deprecated, потом 410

Куда дальше

  • go/headers.md — Idempotency-Key, traceparent.
  • go/versioning.md — breaking change → v2, параллельная поддержка.
  • go/errors.md — 410 Gone, 429 как Problem Details.
  • go/batch-async-localization.md — async и длительные операции.