← Back to the section

Sooner or later every API runs into three practical tasks: protection from too-frequent requests, working with files, and gradually retiring old versions. Let's cover each from scratch.

Rate limiting: what happens when a client knocks too often

Imagine you have an open API and someone sends a thousand requests per second — deliberately or by mistake. Without protection your server will either slow down for everyone or crash.

Rate limiting is a cap on the number of requests over a period of time. When a client exceeds the limit, the server responds with 429 Too Many Requests.

A rule of good manners: a 429 response should always include a Retry-After header so the client knows how long to wait before the next attempt. Without it the client has no idea what to do next.

In Go the standard tool is the golang.org/x/time/rate package. It implements the token bucket algorithm: the bucket holds N tokens, each request spends one token, and tokens are refilled at a set rate.

A global limit via 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",
                    "Request limit exceeded, retry in 1 second",
                    traceIDFromCtx(r.Context()))
                return
            }
            // tell the client how many requests it has left
            w.Header().Set("RateLimit-Limit", strconv.Itoa(int(limiter.Limit())))
            w.Header().Set("RateLimit-Remaining", strconv.Itoa(remaining))
            next.ServeHTTP(w, r)
        })
    }
}

// wiring: 100 requests per second for everyone
globalLimiter := rate.NewLimiter(rate.Every(time.Second), 100)

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

The three RateLimit-* headers in the response are a handy signal for clients: they see how many requests they still have and can slow themselves down before hitting a 429.

A per-user limit

A global limit isn't fair: one active user can burn the entire budget and the rest suffer. The solution is to keep a separate Limiter for each user:

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 is safe for concurrent access from different goroutines. For large systems the limiter store is moved to Redis — then the limit works even across multiple service instances.

An alternative: limiting at the gateway level

Often it's simpler to configure rate limiting in the API gateway (nginx, Envoy, Kong) — then nothing is needed in the service code. The choice depends on whether you need limiting logic inside the business code or an infrastructure solution is enough.

File upload: multipart/form-data

When you need to send a file to an API, the standard format is multipart/form-data. This isn't JSON: the request body is split into several parts, each with its own name and content.

Accepting a file

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

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

    // set a limit — 32 MB; anything over it returns an error
    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()

    // read the first 512 bytes to detect the file type
    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
    }

    // return the cursor to the beginning before saving
    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))
}

An important detail: the file type is detected via http.DetectContentType, which looks at the file's content. Checking by the extension in the file name (if ext == ".jpg") is a common mistake — an extension is easy to fake.

After file.Read(buf) the read position shifts by 512 bytes, so before saving you need to return it to the beginning with file.Seek(0, io.SeekStart).

The response after upload

On a successful upload we return 201 Created with the file's metadata:

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

A response without metadata (just 201 with an empty body) is inconvenient: the client doesn't know what identifier the file got or at what URL it can be downloaded.

Downloading a file

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 takes care of supporting range requests (Range), conditional requests (If-Modified-Since, If-Range) and setting the correct Content-Length. Using it is preferable to manually copying bytes with io.Copy.

The Content-Disposition: attachment header tells the browser to download the file rather than open it inside the tab.

Deprecation: how to retire an API properly

When you release a v2 API and want to disable v1, you can't just delete the old endpoints — some clients still use them. The right approach: first declare the version deprecated, give clients time to migrate, then disable it.

There are standard HTTP headers for this:

  • Deprecation: true — a signal that this endpoint is marked as deprecated.
  • Sunset — the date after which the endpoint will stop working (RFC 7231 format).
  • Link — a link to the successor version.

Middleware for a deprecated route

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

Usage — attach it to the whole /api/v1 once /api/v2 is available:

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

Every response from /api/v1 will contain these headers — the client (or its SDK) will see them in the logs and can plan the migration.

An example response from a deprecated endpoint

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

Note: the endpoint keeps responding with 200 — it still works, it just warns about the upcoming shutdown.

After the Sunset date — 410 Gone

Once the date arrives, the endpoint shouldn't just be deleted — it should explicitly respond with 410 Gone. A bare 404 misleads clients — they think the address is wrong rather than that the API was intentionally disabled.

func sunsetHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        writeProblem(w, http.StatusGone,
            "ENDPOINT_REMOVED", "Gone",
            "This endpoint has been removed. Use /api/v2",
            traceIDFromCtx(r.Context()))
    }
}

// replace the whole /api/v1 with the 410 handler
r.Mount("/api/v1", http.HandlerFunc(sunsetHandler()))

Marking an API as deprecated in the OpenAPI specification (deprecated: true) without stating the shutdown date is pointless: the client doesn't know when something will break.

In short

  • Rate limiting responds with 429 Too Many Requests. Retry-After is mandatory on 429 — without it the client doesn't know when to retry.
  • The three headers RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset help clients manage their own load.
  • golang.org/x/time/rate implements a token bucket; for per-user limits use sync.Map or Redis.
  • Files are sent via POST multipart/form-data; the size limit is set in ParseMultipartForm(N).
  • The file type is detected via http.DetectContentType by content, not by the extension in the name.
  • http.ServeContent automatically handles Range and conditional requests on download.
  • A deprecated endpoint is marked with the Deprecation: true, Sunset and Link headers pointing to the successor.
  • After the Sunset date arrives the endpoint responds with 410 Gone rather than simply being deleted.
  • Headers in a Go REST API — Idempotency-Key, traceparent and others.
  • API versioning in Go — when and how to introduce v2, parallel support.
  • RFC 9457 errors in Go — 410 Gone, 429 as Problem Details.
  • Batch, async, localization in Go — asynchronous and long-running operations.