Back to HTTP Methods
Q3Question 3 of 5

REST API: error handling, pagination, versioning, idempotency, content negotiation

Key Concepts
  • Error handling returns the correct HTTP status code with an RFC 9457 Problem Details body, never 200 with a success-false flag.
  • Cursor pagination stays fast at any depth by seeking from the last seen key, while offset pagination degrades linearly.
  • API versioning via URL path segments like /api/v1 lets the contract evolve without breaking existing clients.
  • POST is made safely retryable with an Idempotency-Key header that ensures the operation executes exactly once.
  • Content negotiation uses the Accept header so one endpoint can serve JSON or XML, returning 406 for unsupported formats.
  • All five concerns are centralised: errors in @RestControllerAdvice, versioning in URL paths, and idempotency in the service layer.

Definition

A production REST API returns consistent Problem Details errors with correct status codes, paginates with cursors for deep pages, versions its contract, uses Idempotency-Keys so retried POSTs run once, and negotiates JSON or XML through Accept and Content-Type.
  • Error handling – return the correct HTTP status code (400, 404, 409, 500) with a consistent RFC 9457 Problem Details body, never 200 with {"success": false}.
  • Pagination – split large collections into pages; cursor (keyset) pagination stays fast at any depth while offset pagination degrades linearly.
  • API versioning – evolve the contract without breaking old clients, typically via a URL path segment like /api/v1.
  • Idempotency – GET, PUT, and DELETE are naturally idempotent; POST becomes safely retryable with a client-supplied Idempotency-Key header.
  • Content negotiation – the Accept header tells the server what format the client wants (JSON or XML), and the Content-Type header tells the server what format the client sent.

Think of National Bank of India's online system as a very disciplined bank branch. Error handling is the printed rejection slip that always has the same layout, a clear reason, and the right stamp, so Rajesh's app always knows how to read it. Pagination is how the clerk hands you the statement in neat pages instead of one long roll, and a smart clerk remembers the last entry you saw instead of recounting from the top. Versioning keeps the old form valid for existing customers while rolling out a new one. Idempotency guarantees that if Rajesh taps Transfer twice because his network glitched, the money only moves once. Content negotiation is the clerk asking whether you want this in English or Hindi and giving you the format you asked for.

Understanding the Concepts

What is structured error handling in REST?

The first thing that separates a production API from a demo is error handling. The anti-pattern is returning 200 OK with {"success": false}, because every client has to parse the body to detect failure, and caches, proxies, and monitoring all treat a 200 as success. The correct approach is to return the right status code: 400 for bad input, 404 for missing, 409 for conflict, 422 for business-rule failure, 500 for server crash.

The body should be a consistent RFC 9457 Problem Details document with a stable type URL that clients switch on, a human-readable detail, and a traceId for debugging.

JAVA
package com.bank.exception;

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.slf4j.MDC;
import java.net.URI;
import java.time.Instant;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AccountNotFoundException.class)
    public ProblemDetail handleNotFound(AccountNotFoundException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, e.getMessage());
        pd.setType(URI.create("https://api.nbi.in/problems/account-not-found"));
        pd.setTitle("Account Not Found");
        pd.setProperty("traceId", MDC.get("traceId"));
        return pd;
    }

    @ExceptionHandler(InsufficientFundsException.class)
    public ProblemDetail handleInsufficient(InsufficientFundsException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
        pd.setType(URI.create("https://api.nbi.in/problems/insufficient-funds"));
        pd.setTitle("Insufficient Funds");
        pd.setProperty("traceId", MDC.get("traceId"));
        return pd;
    }
}

What is pagination and why does offset break at deep pages?

Pagination splits large collections into navigable chunks. Offset pagination uses LIMIT 20 OFFSET 40000, but the database must walk and discard forty thousand rows before returning the next twenty, so deep pages are slow. New inserts can also shift rows between pages, causing duplicates or gaps.

Cursor (keyset) pagination remembers the last key seen and queries forward: WHERE id > :lastId ORDER BY id LIMIT 20. The database uses the index to seek directly to the right spot, so the ten-thousandth page is as fast as the first.

JAVA
package com.bank.controller;

import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/v1/accounts/{accountId}/transactions")
public class TransactionController {

    // Cursor pagination: fast at any depth, stable under concurrent inserts.
    @GetMapping
    public CursorPage<TransactionResponse> list(
            @PathVariable String accountId,
            @RequestParam(required = false) String cursor,
            @RequestParam(defaultValue = "20") int limit) {
        return transactionService.findAfter(accountId, cursor, limit);
    }
}

// Response:
// {
//   "content": [{...}, {...}],
//   "nextCursor": "eyJpZCI6IlRYTi0zOTk4MCJ9",
//   "limit": 20,
//   "hasNext": true
// }

How does API versioning work?

Versioning lets you change the contract without breaking existing clients. The most common and most visible approach is a URL path segment: /api/v1/accounts today and /api/v2/accounts when the response shape changes. Other approaches include a custom header (X-API-Version: 2) and a versioned media-type (Accept: application/vnd.bank.v2+json).

JAVA
// URL path versioning: clearest for clients and routing.
@RestController
@RequestMapping("/api/v1/accounts")
public class AccountControllerV1 {
    @GetMapping("/{id}")
    public AccountResponseV1 get(@PathVariable String id) {
        return accountService.getV1(id);
    }
}

@RestController
@RequestMapping("/api/v2/accounts")
public class AccountControllerV2 {
    @GetMapping("/{id}")
    public AccountResponseV2 get(@PathVariable String id) {
        return accountService.getV2(id); // New response shape
    }
}

How does idempotency protect against duplicate operations?

GET, PUT, and DELETE are idempotent by definition: repeating them does not change the result. POST is not idempotent: posting a transfer twice moves money twice. The industry solution is an Idempotency-Key header. The client generates a unique key per operation. The server stores the key and result. A retry with the same key returns the stored result instead of creating a new transfer.

JAVA
package com.bank.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/v1/transfers")
public class TransferController {

    @PostMapping
    public ResponseEntity<TransferResponse> create(
            @RequestHeader("Idempotency-Key") String key,
            @Valid @RequestBody TransferRequest req) {
        // First call: process transfer, store result under key.
        // Retry with same key: return stored result, no new transfer.
        return ResponseEntity.ok(transferService.createOnce(key, req));
    }
}

How does content negotiation work?

The client states what it wants with the Accept header and what it is sending with the Content-Type header. If Rajesh's app sends Accept: application/xml, Spring serializes the response as XML. If it sends Accept: application/json, it returns JSON. The controller code does not change.

If the client asks for a format the server cannot produce, the server returns 406 Not Acceptable.

JAVA
// Content negotiation: one endpoint, multiple formats.
@GetMapping(value = "/{id}",
        produces = {"application/json", "application/xml"})
public AccountResponse get(@PathVariable String id) {
    return accountService.findById(id);
    // Spring picks JSON or XML based on the Accept header.
    // If Accept is something unsupported, Spring returns 406.
}

How do all five concerns fit together?

In a production banking API, all five concerns work together. A paginated endpoint uses cursor pagination with proper error handling, versioned URLs, content negotiation, and idempotency keys on mutations.

JAVA
// All five in one flow:
// 1. Versioned URL:          /api/v1/transfers
// 2. Content negotiation:    Accept: application/json
// 3. Idempotency:            Idempotency-Key: rent-oct-2026
// 4. Error handling:         422 + Problem Details on insufficient funds
// 5. Pagination:             GET /api/v1/transactions?cursor=TXN-500&limit=20

// POST /api/v1/transfers
// Headers:
//   Accept: application/json
//   Content-Type: application/json
//   Idempotency-Key: rent-oct-2026
// Body: {"from": "SAV2001", "to": "SAV2002", "amount": 50000}
// Response: 201 Created  or  422 + Problem Details

What are common mistakes when handling these concerns?

The biggest mistake is returning 200 for errors, which blinds monitoring and retry logic. Another is using offset pagination for deep, append-heavy data like transaction history. A third is not adding idempotency keys to POST endpoints that move money. A fourth is breaking v1 clients when launching v2.

JAVA
// Wrong: 200 for error, no versioning, no idempotency key.
@PostMapping("/transfer")
public Map<String, Object> transfer(@RequestBody TransferRequest req) {
    try {
        transferService.create(req);
        return Map.of("success", true);
    } catch (Exception e) {
        return Map.of("success", false, "error", e.getMessage()); // 200 + error in body!
    }
}

// Correct: proper status, versioning, idempotency, Problem Details.
@PostMapping("/api/v1/transfers")
public ResponseEntity<TransferResponse> create(
        @RequestHeader("Idempotency-Key") String key,
        @Valid @RequestBody TransferRequest req) {
    TransferResponse resp = transferService.createOnce(key, req);
    return ResponseEntity.created(URI.create("/api/v1/transfers/" + resp.id()))
            .body(resp);
}
// Errors are handled centrally by @RestControllerAdvice with Problem Details.

What should a senior engineer know about these five concerns?

A senior engineer treats all five as non-negotiable for any production API. They know that each concern came from a real production pain: confusing error screens, slow deep pages, a breaking contract change, a double-charged customer, and a partner that needed XML. They also know that the five interact: error handling must be consistent across all versions, pagination cursors must survive versioning, and idempotency keys must be stored atomically with the operation result to prevent race conditions.

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│       Five Production REST API Concerns                   │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │ 1. Error     │  │ 2. Pagination│  │ 3. Versioning│  │
│  │ Handling     │  │              │  │              │  │
│  │              │  │ Offset:      │  │ /api/v1/...  │  │
│  │ 404 + RFC    │  │ LIMIT+OFFSET │  │ /api/v2/...  │  │
│  │ 9457 Problem │  │ (slow deep)  │  │              │  │
│  │ Details      │  │              │  │ Old clients  │  │
│  │              │  │ Cursor:      │  │ keep working │  │
│  │ Central      │  │ WHERE id >   │  │              │  │
│  │ @ControllerA │  │ :lastId      │  │              │  │
│  │ dvice        │  │ (fast always)│  │              │  │
│  └──────────────┘  └──────────────┘  └──────────────┘  │
│                                                          │
│  ┌──────────────┐  ┌──────────────────────────────────┐ │
│  │ 4. Idempot-  │  │ 5. Content Negotiation            │ │
│  │ ency         │  │                                    │ │
│  │              │  │ Client: Accept: application/json   │ │
│  │ GET,PUT,DEL  │  │ Server: Content-Type: app/json     │ │
│  │ = natural    │  │                                    │ │
│  │              │  │ Client: Accept: application/xml    │ │
│  │ POST =       │  │ Server: Content-Type: app/xml      │ │
│  │ Idempotency  │  │                                    │ │
│  │ -Key header  │  │ Unsupported → 406 Not Acceptable  │ │
│  └──────────────┘  └──────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

Key Takeaway

In a banking REST API, five concerns are non-negotiable because together they make an API production-grade. The first is error handling: never return 200 with {"success": false}, because that blinds monitoring, clients, and retry logic. Instead return the correct status code with a consistent RFC 9457 Problem Details body built in one @RestControllerAdvice class, so every error the mobile app receives looks identical.

The second is pagination. For Rajesh with fifteen years of transactions, offset pagination degrades linearly because the database skips all preceding rows. Cursor pagination remembers the last key seen and the query uses an index seek, so the ten-thousandth page is as fast as the first. The third is versioning, done with a URL path segment so v1 and v2 live side by side without breaking existing clients.

The fourth is idempotency. GET, PUT, and DELETE are naturally idempotent, but POST is not, so for transfers the team requires an Idempotency-Key header. The server stores the result against that key, and if Rajesh double-taps Pay Rent on a flaky network, the retry returns the stored transfer and the money moves exactly once. The fifth is content negotiation: one endpoint serves JSON and XML by honouring the Accept header, and if a client asks for an unsupported format the server returns 406.

Each of these came from a real production pain at National Bank of India: confusing error screens from 200-for-everything, slow deep pages from offset pagination, a breaking contract change that crashed old app versions, a double-charged customer from a retried POST, and a partner that needed XML while the mobile app needed JSON. Getting all five right is what lets a banking API scale to millions of customers without surprises.

Remember one sentence: a production REST API earns trust through correct error status codes, cursor pagination, stable versioning, idempotency keys on POST, and content negotiation via the Accept header.

---

Share
Use ← → arrow keys to navigate