← Back to the section

Three independent topics that often come up together once an API grows beyond simple CRUD: overload protection, handling binary data, and controlled version retirement.

When a client makes too many requests

Any public or high-load API sooner or later runs into a problem: a single client makes thousands of requests per minute and swamps the server. To protect against this, you introduce rate limiting — a cap on the number of requests per unit of time.

If the limit is exceeded, the server returns status 429 Too Many Requests:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json

{
  "type": "urn:problem:order-service:rate-limit-exceeded",
  "status": 429,
  "title": "Too Many Requests",
  "detail": "Request limit exceeded. Retry in 30 seconds.",
  "code": "RATE_LIMIT_EXCEEDED"
}

The key detail is the Retry-After: 30 header. It tells the client: wait 30 seconds and try again. Without it, the client doesn't know when to retry and starts hammering the server continuously — which only makes things worse.

Inform the client in advance

A good practice is to include limit information in every successful response, not only when the limit is exceeded:

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 57
RateLimit-Reset: 1719849600

{ ... }
  • RateLimit-Limit — how many requests are allowed within the window.
  • RateLimit-Remaining — how many are still available.
  • RateLimit-Reset — Unix timestamp of the moment the counter resets.

The client sees that 57 out of 100 remain and can slow down ahead of time — instead of suddenly getting a 429.

In OpenAPI the 429 response is documented like this:

"429":
  description: 'Too Many Requests'
  headers:
    Retry-After:
      schema: { type: integer }
      description: 'Seconds until the limit resets'
    RateLimit-Limit:
      schema: { type: integer }
    RateLimit-Remaining:
      schema: { type: integer }
    RateLimit-Reset:
      schema: { type: integer }
      description: 'Unix timestamp of the window reset'
  content:
    application/problem+json:
      schema:
        $ref: "#/components/schemas/ProblemDetails"

A common mistake: returning 429 but without Retry-After and without RateLimit-* headers. The client gets no information at all — it either does a blind exponential backoff or keeps spamming.

File upload

JSON is convenient for structured data, but it's a poor fit for files. If you encode a file in Base64 and put it in JSON, it balloons by 33% and isn't streamable. If you send raw bytes in the body, you lose the metadata (file name, type).

The right format for file upload is multipart/form-data. It lets you send a file as binary data together with metadata in a single request.

Where to send the request

A file is a nested resource. The rule is simple: a file belongs to some entity, and the URL reflects that:

POST /api/v1/documents/{id}/attachments     # attachment to a document
POST /api/v1/users/me/avatar               # user avatar

What the request looks like

POST /api/v1/documents/{id}/attachments
Content-Type: multipart/form-data; boundary=----Boundary

------Boundary
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf

<binary data>
------Boundary
Content-Disposition: form-data; name="description"

March report
------Boundary--

A single request carries the file with its name and type, plus any text fields.

Constraints are declared in OpenAPI:

requestBody:
  content:
    multipart/form-data:
      schema:
        type: object
        required: [file]
        properties:
          file:
            type: string
            format: binary
            description: 'Max 10 MB. Allowed types: PDF, PNG, JPG'
          description:
            type: string
            maxLength: 500

Response to an upload — 201 + metadata

After a successful upload the server returns 201 Created and the metadata of the stored file:

{
  "attachmentId": "550e8400-...",
  "fileName": "report.pdf",
  "contentType": "application/pdf",
  "size": 1048576,
  "uploadedAt": "2026-05-26T10:30:00Z"
}

Downloading a file

For a download you use a plain GET request, but the response contains binary data with the appropriate headers:

GET /api/v1/documents/{id}/attachments/{attachmentId}

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"
Content-Length: 1048576

<binary data>

Content-Disposition: attachment; filename="..." — the browser will save the file with the correct name. Without this header the file is saved unnamed or with the name of the URL path.

Common mistakes:

  • Upload via JSON with Base64 — inflated size, no streaming.
  • Missing Content-Disposition on download — the browser doesn't know the file name.
  • No maximum size declared in OpenAPI — the client doesn't know the constraints.

Deprecation — how to properly retire endpoints

The situation: you have /api/v1/orders/{id}/status, but you've written a more convenient /api/v2/orders/{id}. You need to move all clients to the new version, but you can't simply delete the old one — there are hundreds of active integrations on it.

The solution is a controlled retirement through standard headers.

Step 1: mark it in OpenAPI

/api/v1/orders/{id}/status:
  get:
    deprecated: true
    summary: 'Get order status'
    description: 'DEPRECATED: use GET /api/v2/orders/{id}. Will be removed after 2026-09-01.'

The deprecated: true flag is a signal for tooling: SDK generators will strike through this method, and the documentation will show a warning.

Step 2: add headers to the response

While the endpoint still works, every response from it carries a warning:

HTTP/1.1 200 OK
Sunset: Sat, 01 Sep 2026 00:00:00 GMT
Deprecation: true
Link: </api/v2/orders/{id}>; rel="successor-version"
  • Sunset (RFC 8594) — the exact shutdown date.
  • Deprecation: true — a flag that the endpoint is deprecated.
  • Link with rel="successor-version" — a link to the alternative.

Clients that monitor headers (libraries, SDKs) will automatically notice the deprecation and start signaling their developers.

Step 3: notify consumers

Headers aren't enough — you need to actively communicate: changelog, mailing list, Slack, internal developer portal. The sooner they find out, the more time they have to migrate.

The standard period between announcing a deprecation and the actual shutdown is 6–12 months.

Step 4: after Sunset — 410 Gone

When the deadline passes, the endpoint isn't removed silently — it returns 410 Gone with an explanation:

{
  "type": "urn:problem:order-service:endpoint-removed",
  "status": 410,
  "title": "Gone",
  "detail": "The endpoint has been removed. Use GET /api/v2/orders/{id}.",
  "code": "ENDPOINT_REMOVED"
}

410 differs from 404: 404 says "not found," 410 says "it existed, but it's intentionally gone now." The client immediately understands — this isn't a network error, it's a closed API.

A common mistake: marking deprecated: true in OpenAPI but not adding Sunset. The client doesn't know the deadline — "deprecated" turns into "we'll remove it someday," and the migration never happens.

In short

  • Rate limiting: on excess — 429 + Retry-After (seconds until reset). Add RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset to every successful response. A 429 without headers is useless to the client.
  • Files are uploaded via POST multipart/form-data to a nested resource. Not Base64 in JSON. Size and type constraints are documented in OpenAPI.
  • Downloading — a GET with a binary Content-Type and Content-Disposition: attachment; filename="...".
  • Deprecation: deprecated: true in OpenAPI + Sunset + Deprecation: true + Link rel=successor-version in responses. After the Sunset date — 410 Gone with a hint at the alternative. The period between announcement and shutdown is at least 6 months.
  • Errors and the RFC 9457 format — the body format for 429 and 410.
  • API versioning — how to build v1 → v2 and manage the transition.
  • Batch requests and asynchrony — an alternative when you need to reduce load.
  • JSON and response format — general rules for response structure.