Last month I watched an AI agent try to use one of my REST APIs for twenty minutes. It read the OpenAPI spec, called the list endpoint, got a 500 back with an HTML error page, retried immediately, got the same thing, and then confidently told the user my service was down.
The service was not down. A downstream call had timed out, my global exception handler swallowed it, and Spring's default whitelabel error response went out the wire. A human developer would have opened a browser, seen the HTML, and emailed me. An agent just retried until it ran out of patience and made something up.
That afternoon fixed how I think about REST API design. Most "REST best practices" articles are eternal: use nouns, use plural, return proper status codes. That advice was right in 2016 and it is still right. But something specific changed in the last two years, and it changes which practices actually matter now: a growing share of your API traffic comes from machines that retry aggressively, parse errors literally, and never read your Confluence page.
Here is what actually changed in 2026, and the Spring Boot code for each shift. I have spent six years building Spring Boot services, and every pattern below is one I either use in production or added after an incident taught me to.
What changed: your API consumers are now agents, not just developers
Three concrete shifts drive everything in this article.
- LLM agents call APIs directly now. Coding assistants, automation agents, and MCP servers all make HTTP calls based on your OpenAPI document. They do not read your docs site. The spec is the documentation.
- Agents retry harder than any load test you have run. A human types a URL once. An agent with a retry loop will hit a failing endpoint ten times in five seconds, and three parallel agents will turn one flaky endpoint into an outage.
- Errors and pagination are where agents fall apart. Vague error bodies, offset pagination on large tables, and missing idempotency are the three things that make agent traffic dangerous and expensive.
The good news: most of the fixes are small, and Spring Boot supports almost all of them natively now. Here they are, in the order I would implement them.
1. Return RFC 9457 Problem Details, not ad-hoc error maps
If you are still returning error responses like {"error": "something went wrong", "status": 500}, stop. RFC 9457, which obsoletes RFC 7807, defines a standard JSON error format, and Spring Framework 6+ supports it out of the box with the ProblemDetail class. Every serious API client now recognizes it, and LLM agents parse it far more reliably than custom formats, because the fields carry meaning instead of convention.
Here is a production-ready setup in Spring Boot:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Order not found");
pd.setType(URI.create("https://api.example.com/errors/order-not-found"));
pd.setProperty("orderId", ex.getOrderId());
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail
.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setTitle("Invalid request");
Map<String, String> fields = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(fe -> fields.put(fe.getField(), fe.getDefaultMessage()));
pd.setProperty("errors", fields);
return pd;
}
}
The response body an agent receives looks like this:
{
"type": "https://api.example.com/errors/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "No order with id 87231",
"orderId": 87231
}
Why the type field matters more than you think: it is a stable, dereferenceable identifier for the error class. An agent can match on type without parsing prose. A human can open the URL. One field serves both. If you do only one thing from this article, do this one.
2. Make idempotency a first-class concern, not an afterthought
Agents retry. Your payment endpoint should not charge twice because of it. The standard answer is an idempotency key: the client sends a unique key with each logical operation, and your server treats repeated requests with the same key as the same operation, returning the stored response instead of re-executing.
Stripe popularized this years ago, and in 2026 it is table stakes for any endpoint that writes. Here is a minimal Spring implementation with Redis, which is where this state belongs if you run more than one instance:
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> createPayment(
@RequestBody PaymentRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
PaymentResponse cached = idempotencyStore.get(idempotencyKey);
if (cached != null) {
return ResponseEntity.ok(cached); // replay, not re-execute
}
PaymentResponse response = paymentService.charge(request);
idempotencyStore.put(idempotencyKey, response, Duration.ofHours(24));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
The real implementation needs two guards this sketch omits. First, handle concurrent duplicate requests by locking on the key, so two simultaneous retries do not both execute. Second, if a request arrives with an existing key but a different body, return 422, because that means the client is reusing keys for different operations, which is a bug on their side and you want it loud.
What I would do differently: I added idempotency keys to a booking endpoint only after a partner's retry logic double-booked inventory during a network blip. It took an afternoon to add. It should have been there from day one.
3. Switch to cursor pagination before your table gets big
Offset pagination (?page=50&size=20) is fine at 10,000 rows and quietly terrible at 10 million. Two reasons. Deep offsets force the database to scan and discard everything before the offset, so page 5,000 costs as much as the 4,999 pages before it. And offset pagination is unstable: if a row is inserted between calls, your client sees duplicates or skips rows entirely. A human clicking "next" does not care. An agent iterating all records to build an index cares a lot.
Cursor pagination fixes both. The client passes a cursor, usually an opaque encoding of the last seen row's sort position, and you query everything after it:
@GetMapping("/orders")
public Page<Order> listOrders(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") int limit) {
if (limit > 100) {
limit = 100; // hard cap, no matter what the client asks
}
if (cursor == null) {
return orderRepository.findFirstPage(limit);
}
Cursor c = Cursor.decode(cursor); // typically (id, createdAt) tuple
return orderRepository.findAfter(c.id(), c.createdAt(), limit);
}
With the corresponding repository query on a composite index of (created_at, id):
SELECT * FROM orders
WHERE (created_at, id) < (:cursorCreatedAt, :cursorId)
ORDER BY created_at DESC, id DESC
LIMIT :limit
The response includes a nextCursor that is null when the client has reached the end. Deep pages now cost the same as the first page, and no row can be skipped or duplicated by an insert mid-iteration.
Honest trade-off: cursors break random access. You cannot jump to page 40. For admin UIs that genuinely need "page 40 of 400," keep offset for that internal surface and use cursors for the public API where iteration, not browsing, is the pattern.
4. Respect rate limiting headers, and make yours machine-readable
Rate limiting went from a nicety to a survival requirement once agents showed up. Two rules.
Cap everything server-side. In the pagination code above you saw the hard limit = 100 cap. Do the same for every list endpoint, every batch size, every page depth request parameter. Never trust a client-supplied limit, because sooner or later an agent will ask for limit=100000 and your database will oblige.
When you throttle, tell the client when to come back. A bare 429 with no guidance invites the exact hammering you are trying to prevent. Return standard headers so well-behaved clients, including agents, can back off correctly:
HttpResponse.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", "30")
.header("X-RateLimit-Limit", "100")
.header("X-RateLimit-Remaining", "0")
.header("X-RateLimit-Reset", String.valueOf(epochSecondsOfWindowReset))
.build();
If you use Bucket4j, the de facto Java rate limiting library, it integrates directly with Spring Webflux and Spring MVC and can populate these headers for you from the bucket state, so the values cannot drift from reality.
5. Version deliberately and sunset loudly
Breaking changes are inevitable. Surprise breakage is not. The practices that aged well here:
-
Path versioning (
/api/v1/orders) has won over header versioning for most teams, because it is debuggable from a URL in a log line, cacheable by CDNs, and visible in OpenAPI. Headers hide everything. -
Never remove a field silently. Deprecate it: keep returning it, mark it
deprecated: truein the OpenAPI document, and add aDeprecationandSunsetHTTP header to responses. RFC 8594 defines theSunsetheader format, and API gateways and monitoring tools increasingly parse it to warn you when clients still call a dying endpoint. - Give one quarter of notice minimum. If your OpenAPI doc shows 40 percent of traffic still on a deprecated endpoint two weeks before sunset, that is a conversation, not a cutover.
@GetMapping("/orders")
public Page<Order> listOrders() {
// v2 exists; this endpoint sunsets 2026-12-31
}
with a filter or gateway rule attaching:
Deprecation: version="v1"
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migration-v2>; rel="deprecation"
6. Treat your OpenAPI document as the product, because agents do
This is the biggest 2026 shift. When your consumers are LLM agents, the OpenAPI document is not generated documentation. It is the interface. springdoc-openapi gets you 90 percent there:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
Then invest in the parts the generator cannot infer. Write real description text on every field, because that text is what an agent reads to decide what your endpoint does. Declare exact formats for dates and IDs. Enumerate status codes with their ProblemDetail types. Mark required fields as required. A spec where every string is "type": "string" with no description is, to an agent, an API with no documentation at all.
Two team rules that came out of my incident:
- Spec changes are reviewed like code changes, in the same pull request as the endpoint change. If the spec and the behavior drift, agents fail in ways your tests will never catch, because your tests use the real code, not the spec.
- Contract tests run against the spec, not just the implementation. A drift check in CI that fails when the served payload shape stops matching the published schema is cheap insurance.
The 2026 checklist
Here is the whole article as a reviewable list. I keep this pinned in every service repo I touch:
-
Errors: RFC 9457 ProblemDetail on every error path, with a stable
typeURI per error class -
Idempotency:
Idempotency-Keysupport on every POST/PUT/PATCH that writes, with duplicate-body detection returning 422 -
Pagination: cursor-based on public list endpoints, server-enforced max page size, null
nextCursorat the end -
Rate limiting: Bucket4j or equivalent,
Retry-AfterandX-RateLimit-*headers on every 429 -
Versioning: path versions,
DeprecationandSunsetheaders on dying endpoints, one quarter minimum notice - OpenAPI: descriptions on every field, enums enumerated, required flags accurate, spec drift checked in CI
- Timeouts: explicit client timeouts on every outbound call, because your API's 500s usually start as somebody else's slow endpoint
- Observability: a request ID in every ProblemDetail extension field, so a user-reported error string finds its log line in one search
None of this is exotic. That is the point. The practices that matter in 2026 are the old virtues, error clarity, retry safety, bounded work, honest specs, hardened for a world where the client on the other end retries ten times and reads only what you machine-encoded. The APIs that thrive are the ones that are boring to integrate with.
Have you had an AI agent integrate against your API yet? I am collecting war stories, and the pagination ones are always worse than people expect. What was your experience?
I write about Java, Spring Boot, and AI every week. Subscribe, it is free, and next week I am covering what those same API conventions look like when the caller is an MCP server.
Top comments (0)