Опирается на правила:
R-RATE-1..3,R-RATE-X1,R-FILE-1..5,R-DEP-1..3,R-DEP-X1→ раздел Rate limiting.
Важно знать
- Rate limiting →
429 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-After | R-RATE-X1 | Retry-After обязателен |
429 без RateLimit-* | R-RATE-X1 | RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset |
Тип файла по расширению (if ext == ".jpg") | R-FILE-3 | http.DetectContentType |
Ответ 201 без метаданных файла | R-FILE-4 | тело с imageId, url, size, contentType |
deprecated: true в OpenAPI без Sunset | R-DEP-X1 | Sunset + дата отключения |
Удаление эндпоинта без 410 Gone после Sunset | R-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 и длительные операции.