ResponseEntity and HTTP Status Codes in Spring Boot
Introduction
This tutorial shows how a Spring REST controller takes full control of its HTTP response withResponseEntity: the status code, the headers and the body. We will start with the families of HTTP status codes and the ones REST APIs use most. We will then return 201 Created with a Location header pointing to the new resource, 404 from an Optional, and 202 Accepted for work that finishes later. Next we will add response headers - a custom total count and caching headers - and use an ETag so that clients receive 304 Not Modified instead of downloading unchanged data again. Lastly, we will compare ResponseEntity with @ResponseStatus and exceptions, and decide when to use each.(This tutorial extends the book API from Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController.)
HTTP status codes in a REST API
Every HTTP response starts with a three-digit status code, grouped by its first digit -- 2xx - success:
200 OK(here is the result),201 Created(a new resource exists, seeLocation),202 Accepted(the work will be done later),204 No Content(done, nothing to return). - 3xx - redirection:
301/308(moved permanently),304 Not Modified(your cached copy is still valid). - 4xx - the client's fault:
400 Bad Request(invalid input),401 Unauthorized(not logged in),403 Forbidden(not allowed),404 Not Found,405 Method Not Allowed,409 Conflict(clashes with the current state),415 Unsupported Media Type,422 Unprocessable Content,429 Too Many Requests. - 5xx - the server's fault:
500 Internal Server Error(a bug),503 Service Unavailable(overloaded or down for maintenance).
200 with an error message in the body.
ResponseEntity
ResponseEntity<T> represents the whole HTTP response: a status, headers, and a body of type T. Returning it from a controller method, instead of returning T directly, lets the method decide all three at runtime. It has a fluent builder with a static method per common status - ok(), created(uri), accepted(), noContent(), badRequest(), notFound() - and status(...) for any other code -
ResponseApp.java - controlling status, headers and body with ResponseEntity
//ResponseApp.java
package com.javabrahman.rest.response;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
record Book(Long id, String title, int version) {
}
// What a client sends to create a book: the server chooses the id and the version
record NewBook(String title) {
}
@RestController
@RequestMapping("/api/books")
class BookController {
private final Map<Long, Book> books = new ConcurrentHashMap<>(Map.of(1L, new Book(1L, "Effective Java", 3)));
private final AtomicLong nextId = new AtomicLong(1);
// 200 with a custom header, or the list
@GetMapping
ResponseEntity<List<Book>> all() {
List<Book> list = books.values().stream().toList();
return ResponseEntity.ok()
.header("X-Total-Count", String.valueOf(list.size()))
.body(list);
}
// 200 with the book, or 404 when the Optional is empty
@GetMapping("/{id}")
ResponseEntity<Book> one(@PathVariable long id) {
return ResponseEntity.of(Optional.ofNullable(books.get(id)));
}
// 201 with a Location header pointing to the new book
@PostMapping
ResponseEntity<Book> create(@RequestBody NewBook book) {
Book saved = new Book(nextId.incrementAndGet(), book.title(), 1);
books.put(saved.id(), saved);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(saved.id()).toUri();
return ResponseEntity.created(location).body(saved);
}
// 200 with caching headers, or 304 if the client's copy is current
@GetMapping("/{id}/cached")
ResponseEntity<Book> cached(@PathVariable long id, WebRequest request) {
Book book = books.get(id);
if (book == null) {
return ResponseEntity.notFound().build();
}
String etag = "\"" + book.id() + "-v" + book.version() + "\"";
if (request.checkNotModified(etag)) {
return null; // Spring has already set status 304 and the ETag header
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePublic())
.body(book);
}
}
record ReportJob(String id, String status) {
}
@RestController
@RequestMapping("/api/reports")
class ReportController {
private final Map<String, ReportJob> jobs = new ConcurrentHashMap<>();
// 202: the report is generated in the background; the client polls the Location
@PostMapping
ResponseEntity<ReportJob> start() {
ReportJob job = new ReportJob(UUID.nameUUIDFromBytes("sales-report".getBytes()).toString().substring(0, 8), "QUEUED");
jobs.put(job.id(), job);
URI status = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(job.id()).toUri();
return ResponseEntity.accepted().location(status).body(job);
}
// any status code, here 409 Conflict
@PostMapping("/{id}/cancel")
ResponseEntity<String> cancel(@PathVariable String id) {
return ResponseEntity.status(HttpStatus.CONFLICT).body("Job " + id + " has already started and cannot be cancelled");
}
}
@SpringBootApplication
public class ResponseApp {
public static void main(String[] args) {
SpringApplication.run(ResponseApp.class, args);
}
}
201 Created with a Location header
When aPOST creates a resource, the standard answer is 201 Created with a Location header holding the new resource's URL, so the client knows where to find it -
OUTPUT of creating a book and following its Location> curl -i -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Clean Code"}'
HTTP/1.1 201
Location: http://localhost:8080/api/books/2
Content-Type: application/json
{"id":2,"title":"Clean Code","version":1}
> curl -i http://localhost:8080/api/books/2
HTTP/1.1 200
Content-Type: application/json
{"id":2,"title":"Clean Code","version":1}
Explanation of the output
- The client sent only a title, as a
NewBook; the server chose the id and the version. Using a separate record for the request body keeps clients from setting fields that belong to the server. ResponseEntity.created(location)set the status to201and theLocationheader in one call;.body(saved)added the stored book.ServletUriComponentsBuilder.fromCurrentRequest()starts from the URL of the current request,/api/books, and.path("/{id}")withbuildAndExpand(2)appends the new id. Building the URL from the request keeps it correct behind any host name or port.- The follow-up
GETon theLocationURL returned the new book.
200 or 404 from an Optional, and custom headers
OUTPUT of reading the list, an existing book and a missing one> curl -i http://localhost:8080/api/books
HTTP/1.1 200
X-Total-Count: 1
Content-Type: application/json
[{"id":1,"title":"Effective Java","version":3}]
> curl -i http://localhost:8080/api/books/1
HTTP/1.1 200
Content-Type: application/json
{"id":1,"title":"Effective Java","version":3}
> curl -i http://localhost:8080/api/books/99
HTTP/1.1 404
Content-Length: 0
ResponseEntity.of(optional) answers 200 with the value when the Optional holds one, and 404 with an empty body when it is empty - a one-line way to handle "find by id". The list response carries an X-Total-Count header added with .header(name, value); headers like this are typical for paged lists, covered in Pagination, Sorting and FilteringClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs.
202 Accepted and other status codes
Some requests start work that takes longer than an HTTP request should - generating a report, processing a video. The API answers202 Accepted at once, with a Location where the client can check the job's status later. ResponseEntity.status(...) covers every other code, such as 409 Conflict -
OUTPUT of starting a background report and trying to cancel it> curl -i -X POST http://localhost:8080/api/reports
HTTP/1.1 202
Location: http://localhost:8080/api/reports/a75f07aa
Content-Type: application/json
{"id":"a75f07aa","status":"QUEUED"}
> curl -i -X POST http://localhost:8080/api/reports/a75f07aa/cancel
HTTP/1.1 409
Content-Length: 56
Content-Type: text/plain; charset=UTF-8
Job a75f07aa has already started and cannot be cancelled
202 response told the client that the report is queued, and where to poll. (The job id is derived from a fixed name here, so that the example prints the same id on every run; a real API would use a random id.) The cancel request answered 409 with a plain-text explanation: the request was understood, but it conflicts with the job's current state.
Caching with ETag and 304 Not Modified
An ETag is a version label for a resource, sent in a response header. A client that has the resource cached sends the label back in anIf-None-Match header, and if the resource has not changed the server answers 304 Not Modified with no body - saving bandwidth and work. WebRequest.checkNotModified(etag) does the comparison and sets the 304 -
OUTPUT of a first request, then a request with the ETag, then one with an old ETag> curl -i http://localhost:8080/api/books/1/cached
HTTP/1.1 200
Cache-Control: public, max-age=600
ETag: "1-v3"
Content-Type: application/json
{"id":1,"title":"Effective Java","version":3}
> curl -i http://localhost:8080/api/books/1/cached -H 'If-None-Match: "1-v3"'
HTTP/1.1 304
ETag: "1-v3"
> curl -i http://localhost:8080/api/books/1/cached -H 'If-None-Match: "1-v2"'
HTTP/1.1 200
Cache-Control: public, max-age=600
ETag: "1-v3"
Content-Type: application/json
{"id":1,"title":"Effective Java","version":3}
Explanation of the output
- The first response carried the book, the
ETag"1-v3"(built from the book's id and version), andCache-Control: public, max-age=600, which allows browsers and proxies to reuse it for ten minutes. - The second request sent the same ETag back: the book had not changed, so the answer was
304with no body. - The third sent an old ETag,
"1-v2": the book had changed since, so it received the full response again. - For responses that are expensive to build, Spring's
ShallowEtagHeaderFiltercan compute ETags automatically from the response body - which saves bandwidth, though not the work of building the response.
ResponseEntity, @ResponseStatus or an exception?
- Return the object, plus
@ResponseStatusif needed, when the status is always the same: aGETthat returns200, aDELETEthat returns204. It keeps the method signature simple. - Return
ResponseEntitywhen the status or headers depend on the outcome -201with a computedLocation,200or304, caching headers, custom headers. - Throw an exception for errors - a missing resource, invalid input, a conflict - and translate it into a response in one place, with
ResponseStatusExceptionor an exception handler. That keeps the "happy path" of each method clean; REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail shows how.
Summary
In this tutorial we first went through the HTTP status code families and the codes REST APIs use most. We then usedResponseEntity to answer 201 Created with a Location built by ServletUriComponentsBuilder, 200 or 404 from an Optional, and a list with an X-Total-Count header. Next we answered 202 Accepted for background work and 409 Conflict with status(...), and served a book with an ETag and Cache-Control, getting 304 Not Modified for an unchanged copy. Lastly, we compared ResponseEntity, @ResponseStatus and exceptions. The next tutorial makes sure the input is valid before it is used: Bean Validation with @ValidClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators.Spring REST API Tutorials
Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces
Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces