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-Dispositionon 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.Linkwithrel="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). AddRateLimit-Limit,RateLimit-Remaining,RateLimit-Resetto every successful response. A 429 without headers is useless to the client. - Files are uploaded via
POST multipart/form-datato a nested resource. Not Base64 in JSON. Size and type constraints are documented in OpenAPI. - Downloading — a GET with a binary
Content-TypeandContent-Disposition: attachment; filename="...". - Deprecation:
deprecated: truein OpenAPI +Sunset+Deprecation: true+Link rel=successor-versionin responses. After the Sunset date — 410 Gone with a hint at the alternative. The period between announcement and shutdown is at least 6 months.
What to read next
- 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.