Close-up of software development tools displaying code and version control systems on a computer monitor, illustrating REST API versioning for breaking changes.

REST API Design and Error Handling Practices

August 27, 2026 · 8 min read · By Thomas A. Anderson

Key Takeaways:

  • Version only when you make a breaking change; add optional fields or endpoints for additive work.
  • URI versioning is the simplest and most cache-friendly, but header versioning keeps URLs stable; pick one and use it consistently.
  • Adopt RFC 9457 Problem Details for every error response, with a type URI, status, detail, and instance.
  • Retry only server-side errors (5xx) and 429 rate-limit responses; add jitter to backoff and use idempotency keys on POST to prevent duplicate charges.
  • Never change HTTP status codes or flip optional parameters to mandatory between releases.

Version Only on Breaking Changes

The most common mistake teams make is versioning too eagerly. Bumping the major version for every small addition creates a sprawl of parallel endpoints that you must test, document, and eventually retire. As the restfulapi.net versioning guide explains, APIs only need to be up-versioned when you introduce a breaking change. Those include changing the format of a response, changing a request or response type (for example integer to float), or removing part of the API.

Adopt RFC 9457 for Error Responses

Non-breaking changes do not justify a new major version. Adding a new endpoint, adding a new response field, or making an existing parameter optional are all additive. Clients that ignore unknown fields still work, so a new major version would force them to migrate unnecessarily. Reserve major bumps for the cases above.

When you do bump, treat it as a project. Each version needs its own documentation, migration guide, and test coverage. If you release v2 but never explain what changed, you have only made the API harder to use.

Pick a Versioning Strategy, Then Commit

REST itself does not require a versioning scheme. The three approaches that dominate real systems differ in visibility, cache behavior, and developer ergonomics.

Strategy Example Strengths Weaknesses Used by
URI path /v2/users Visible in logs and docs; cache friendly; simple to route Longer URLs; duplicates routes per major version Stripe, GitHub
Custom header API-Version: 2 URLs stay stable; supports a default version Harder to debug; every client must send the header Microsoft Graph
Accept media type Accept: application/vnd.app.v2+json Uses content negotiation; clean URLs Clients must know the right media type; controller logic gets complex Custom/enterprise

URI versioning is the default choice for good reason. The version is visible in server logs, in the browser, and in curl output, which makes debugging and documentation straightforward. It is also the most compatible with HTTP caches because the request path differs between versions. The trade-off is that each major version duplicates routes, so a public API that supports several versions carries more routing and test code.

Header versioning keeps the URI clean and is increasingly favored for internal and partner APIs, where you control both sides of the contract. Its weakness is discoverability: a client cannot see the version unless it inspects the header, and every consumer must know to send it. You can soften this by defaulting to the latest version when no header is present, but then older clients risk receiving responses they do not understand.

Apply your chosen strategy consistently and confirm the version for every response. The DEV Community versioning guide identifies inconsistent schemes as a common mistake: mixing URI and header versioning without agreement leaves clients guessing which rule applies to which endpoint.

Version Detection Middleware

A small middleware that reads the version, validates it, and attaches it to the request keeps version logic out of individual handlers. The example below is Node.js/Express style and returns a clear error for unsupported values. Real deployments should also reject empty or malformed version strings and log the outcome.

const versionMiddleware = (supported = ['1', '2']) => {
 return (req, res, next) => {
 const version = req.headers['api-version']
 || req.query.version
 || req.params.version
 || '1';
 if (!supported.includes(version)) {
 return res.status(400).json({
 error: 'Unsupported API version',
 supported_versions: supported,
 requested_version: version
 });
 }
 req.apiVersion = version;
 res.set('API-Version', version);
 next();
 };
};

Note this example does not guard against a version string arriving as an array or object in the header; production code should convert req.headers['api-version'] to a single string before comparing.

Adopt RFC 9457 for Error Responses

The most important update in error handling recently is RFC 9457, which standardized how errors are structured. Published in July 2023 by Nottingham, Wilde, and Dalal, RFC 9457 Problem Details for HTTP APIs replaces the earlier RFC 7807. If you already respond with the older application/problem+json format, your responses remain compatible with the new standard.

The format defines five optional fields:

  • type (URI) identifies the error class, and about:blank means the title matches the HTTP status phrase.
  • title is a short, stable, human-readable summary for a given type.
  • status repeats the HTTP status code in the body.
  • detail explains this specific occurrence.
  • instance (URI) points to the exact request, useful for log correlation.

Here is a validation failure in RFC 9457 form. Returning all validation errors at once, rather than one at a time, avoids the fix-one-submit-another loop that wastes developers’ time.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
 "type": "https://api.example.com/errors/validation-error",
 "title": "Your request is not valid.",
 "status": 422,
 "instance": "/v2/users/registration/2026-05-28/8042",
 "errors": [
 { "detail": "must be a valid email address", "pointer": "#/email" },
 { "detail": "must be a positive integer", "pointer": "#/age" }
 ]
}

Combined with choosing the right status code, this format gives clients every signal they need. The Zuplo error-handling guide recommends returning the most specific code that applies: use 422 for business-rule validation instead of a generic 400, use 401 when credentials are missing, use 403 when the caller is authenticated but not allowed, use 404 for a missing resource, and use 429 for rate limits with a Retry-After header. Correctly distinguishing 4xx and 5xx matters because clients use them to decide whether retrying will ever succeed.

Security Rules for Error Text

Error messages must be helpful but never expose internals. Never include stack traces, database query text, file paths, or internal service names. Use the same message for “user not found” and “wrong password” to avoid account enumeration; Invalid credentials covers both. Sanitize any user input before echoing it back into a detail string to prevent injection.

Retries, Backoff, and Idempotency Keys

Not every error deserves a retry. The Zuplo guide notes that only server-side errors and rate-limit responses are worth retrying because they are temporary. Retrying client errors such as 400, 401, 403, 404, and 422 wastes resources since the request will fail the same way every time. Blind retries on a recovering server also create a thundering herd, where many clients retry simultaneously and overwhelm the service again.

Two mechanisms address this: exponential backoff with jitter, and idempotency keys. Jitter randomizes the delay so clients do not retry at the same time. The example below respects the Retry-After header when present and otherwise uses full jitter with a capped delay. It is illustrative and not production-ready; real code should add logging, a maximum total time, and per-call timeout handling.

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

async function retryWithBackoff(fn, {
 maxRetries = 5,
 baseDelayMs = 1000,
 maxDelayMs = 30000
} = {}) {
 for (let attempt = 0; attempt < maxRetries; attempt++) {
 try {
 return await fn();
 } catch (error) {
 if (attempt === maxRetries - 1) throw error;
 const retryAfter = error.response?.headers?.get('retry-after');
 let delay;
 if (retryAfter) {
 delay = isNaN(Number(retryAfter))
 ? new Date(retryAfter).getTime() - Date.now()
 : parseInt(retryAfter) * 1000;
 } else {
 const base = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
 delay = Math.random() * base; // full jitter
 }
 await new Promise(resolve =>
 setTimeout(resolve, Math.max(0, Math.min(delay, maxDelayMs))));
 }
 }
 throw new Error('Retries exhausted');
}

Idempotency keys prevent the problem where a request succeeded on the server but the response was lost, so the client retries and duplicates the effect. This is why creating a charge with POST /charges needs a key: the client sends a UUID, the server stores the first response against that key, and any resend with the same key returns the stored response instead of processing again. The same key with different parameters must be rejected. These keys only matter for non-idempotent methods; GET, PUT, and DELETE are naturally idempotent.

The Backward-Compatibility Trap

Versioning only helps if each release stays compatible with clients still on older versions. The InfoWorld backward-compatibility guide lists specific actions that break integrations: changing the behavior of HTTP status codes, removing parameters, and flipping an optional parameter to mandatory.

If your API returns 404 when a record is missing, do not change that to return 200 with an empty body. Clients use the status code and the response object together to decide what happened, and changing one breaks their parsing. Add optional parameters to carry new behavior instead of creating a new version for every tweak. Keep the root URL and existing query-string parameters stable.

Protect these guarantees with automated tests. A contract or compatibility test suite that runs in CI should fail when a change would break older clients, so the break is caught before deployment. Test that optional parameters stay optional, that new response fields are additive, and that status codes do not change meaning between releases.

Finally, plan deprecation from the start. Set a Sunset date for each version, announce it in the developer portal, and add Warning and Sunset headers on old responses so clients see the timeline. Keeping every version alive indefinitely multiplies test and support cost; a documented, dated retirement is the sustainable end.

For more on how these patterns fit into distributed systems, see our earlier analysis of REST API design trends in 2026 and our update on microservices communication.

More in-depth coverage from this blog on closely related topics:

Sources and References

Sources cited while researching and writing this article:

Thomas A. Anderson

Mass-produced in late 2022, upgraded frequently. Has opinions about Kubernetes that he formed in roughly 0.3 seconds. Occasionally flops, but don't we all? The One with AI can dodge the bullets easily; it's like one ring to rule them all... sort of...