Spring BootHTTP Status Codes
Spring Boot

HTTP Status Codes

HTTP status codes are the standard mechanism for communicating the result of an HTTP request. Spring Boot provides HttpStatus, ResponseEntity, and @ResponseStatus to return correct codes from REST controllers. Using the right code is as important as the response body — clients, proxies, monitoring tools, and retry logic all behave differently based on the status code.

Status Code Classes

HTTP status codes are three-digit integers grouped into five classes by their first digit. Every class has a defined meaning — clients and infrastructure use the class to decide how to handle a response even when they do not recognise the specific code.
Shell
# 1xx — Informational: request received, processing continues
# 2xx — Success: request was received, understood, and accepted
# 3xx — Redirection: further action needed to complete the request
# 4xx — Client Error: request contains bad syntax or cannot be fulfilled
# 5xx — Server Error: server failed to fulfil a valid request

# ── Key property: idempotency and retry behaviour ──────────────────────
# 2xx — success, no retry needed
# 3xx — follow the redirect
# 4xx — do NOT retry (client must fix the request first)
# 429 — retry after the Retry-After interval
# 5xx — may retry (server-side problem, client request was valid)
# 503 — retry after Retry-After interval

# ── Safe vs unsafe for caching ────────────────────────────────────────
# 200 — cacheable by default
# 201, 202, 204 — not cached by default
# 301 — cached indefinitely (permanent redirect)
# 302 — not cached by default
# 4xx, 5xx — not cached (except 404 and 405, which may be cached)

2xx — Success Codes

The 2xx class indicates the request was successfully received, understood, and processed. Choosing the correct 2xx code communicates the precise outcome — whether a resource was created, whether there is a body, or whether processing is still ongoing.
Java
# ── 200 OK ────────────────────────────────────────────────────────────
# The standard success response. Use for GET, PUT, and PATCH when
# the response includes a body.
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
    return ResponseEntity.ok(userService.findById(id));
}

# ── 201 Created ───────────────────────────────────────────────────────
# Resource was created. Always include a Location header pointing to
# the new resource URI. Body is optional but recommended.
@PostMapping
public ResponseEntity<UserResponse> create(
        @RequestBody @Valid CreateUserRequest request,
        UriComponentsBuilder uriBuilder) {
    UserResponse created = userService.create(request);
    URI location = uriBuilder.path("/users/{id}")
        .buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

# ── 202 Accepted ──────────────────────────────────────────────────────
# Request accepted for processing but not yet complete (async).
# Include a way for the client to check progress — a status URL in
# the Location or body.
@PostMapping("/{id}/export")
public ResponseEntity<ExportStatusResponse> startExport(@PathVariable Long id) {
    String jobId = exportService.startAsync(id);
    URI statusUri = URI.create("/jobs/" + jobId);
    return ResponseEntity.accepted()
        .location(statusUri)
        .body(new ExportStatusResponse(jobId, "PENDING"));
}

# ── 204 No Content ────────────────────────────────────────────────────
# Success with no response body. Standard for DELETE and for PUT/PATCH
# when you choose not to return the updated resource.
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build();
}

# ── 206 Partial Content ───────────────────────────────────────────────
# Used with Range requests — returning a portion of a large resource
# (video streaming, large file downloads with resume support).
@GetMapping("/videos/{id}")
public ResponseEntity<Resource> streamVideo(
        @PathVariable Long id,
        @RequestHeader HttpRange range) {
    // ... range handling omitted for brevity
    return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
        .header(HttpHeaders.CONTENT_RANGE, "bytes 0-1023/50000")
        .body(resource);
}

3xx — Redirection Codes

Redirection responses tell the client to look elsewhere for the resource. They are less common in REST APIs than in web applications, but are used for permanent URI changes and OAuth flows.
Java
# ── 301 Moved Permanently ────────────────────────────────────────────
# Resource has a new permanent URI. Clients and search engines should
# update their bookmarks. Response is cached indefinitely.
@GetMapping("/users/{id}")
public ResponseEntity<Void> redirectOldPath(@PathVariable Long id) {
    URI newUri = URI.create("/api/v2/users/" + id);
    return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
        .location(newUri)
        .build();
}

# ── 302 Found ─────────────────────────────────────────────────────────
# Temporary redirect — client should continue using the original URI
# for future requests. Used in OAuth flows.

# ── 304 Not Modified ──────────────────────────────────────────────────
# Client's cached version is still valid — no body returned.
# Used with ETag / If-None-Match and Last-Modified / If-Modified-Since.
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(
        @PathVariable Long id,
        @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {

    UserResponse user = userService.findById(id);
    String etag = """ + user.updatedAt().hashCode() + """;

    if (etag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
            .eTag(etag).build();         // 304 — client cache is current
    }
    return ResponseEntity.ok().eTag(etag).body(user);  // 200 + fresh body
}

4xx — Client Error Codes

Client errors indicate the request was invalid — the client must fix the request before retrying. Never return a 5xx for a client error; doing so breaks retry logic and monitoring.
Java
# ── 400 Bad Request ──────────────────────────────────────────────────
# Malformed JSON, type mismatch, or failed Bean Validation.
# Most common 4xx — return field-level detail when possible.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
        MethodArgumentNotValidException ex) {
    Map<String, String> errors = ex.getBindingResult().getFieldErrors()
        .stream().collect(Collectors.toMap(
            FieldError::getField,
            fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid",
            (a, b) -> a));
    return ResponseEntity.badRequest()
        .body(new ErrorResponse(400, "Validation Failed", errors));
}

# ── 401 Unauthorized ─────────────────────────────────────────────────
# Authentication is required and has not been provided or is invalid.
# (Misleadingly named — it means "unauthenticated", not "unauthorized".)
# Spring Security returns this automatically — include WWW-Authenticate header.

# ── 403 Forbidden ────────────────────────────────────────────────────
# Authenticated but not authorised to perform this action.
# Do not reveal whether the resource exists (use 404 for sensitive resources).
# Spring Security returns this automatically for access denied.

# ── 404 Not Found ────────────────────────────────────────────────────
# Resource does not exist.
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException ex) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND)
        .body(ErrorResponse.of(404, "Not Found", ex.getMessage()));
}

# ── 405 Method Not Allowed ────────────────────────────────────────────
# HTTP method not supported for this URI.
# Spring MVC returns this automatically — include Allow header.

# ── 409 Conflict ─────────────────────────────────────────────────────
# State conflict — duplicate email, optimistic lock failure,
# version mismatch, or business rule violation.
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleConflict(EmailAlreadyExistsException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
        .body(ErrorResponse.of(409, "Conflict", ex.getMessage()));
}

# ── 410 Gone ────────────────────────────────────────────────────────
# Resource existed but was permanently deleted.
# Stronger signal than 404 — tells clients to remove their bookmarks.

# ── 415 Unsupported Media Type ────────────────────────────────────────
# Content-Type of the request body is not supported.
# Spring MVC returns this automatically when consumes does not match.

# ── 422 Unprocessable Entity ──────────────────────────────────────────
# Request is syntactically valid but semantically invalid.
# Some APIs prefer this over 400 for domain-level validation failures.
@ExceptionHandler(DomainValidationException.class)
public ResponseEntity<ErrorResponse> handleDomainValidation(
        DomainValidationException ex) {
    return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
        .body(ErrorResponse.of(422, "Unprocessable Entity", ex.getMessage()));
}

# ── 429 Too Many Requests ─────────────────────────────────────────────
# Rate limit exceeded. Always include Retry-After header.
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ErrorResponse> handleRateLimit(RateLimitExceededException ex) {
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
        .header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()))
        .body(ErrorResponse.of(429, "Too Many Requests", "Rate limit exceeded"));
}

5xx — Server Error Codes

Server errors indicate the server failed to process a valid request. The client request was correct — the problem is on the server side. These are retryable (with backoff); 4xx errors are not.
Java
# ── 500 Internal Server Error ────────────────────────────────────────
# Catch-all for unhandled server-side failures.
# Log the full stack trace server-side; return a generic message to the client
# — never expose internal details (stack traces, SQL errors, file paths).
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
    log.error("Unhandled exception", ex);   // full detail in logs
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(ErrorResponse.of(500, "Internal Server Error",
            "An unexpected error occurred"));   // generic to client
}

# ── 502 Bad Gateway ───────────────────────────────────────────────────
# This server received an invalid response from an upstream service.
# Return when a downstream dependency (another microservice, third-party API)
# returns an unexpected response.
@ExceptionHandler(UpstreamServiceException.class)
public ResponseEntity<ErrorResponse> handleUpstream(UpstreamServiceException ex) {
    return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
        .body(ErrorResponse.of(502, "Bad Gateway",
            "Upstream service returned an invalid response"));
}

# ── 503 Service Unavailable ───────────────────────────────────────────
# Server is temporarily unable to handle the request — overloaded,
# in maintenance, or a critical dependency is down.
# Include Retry-After header when the recovery time is known.
@ExceptionHandler(ServiceUnavailableException.class)
public ResponseEntity<ErrorResponse> handleUnavailable(ServiceUnavailableException ex) {
    return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
        .header("Retry-After", "30")
        .body(ErrorResponse.of(503, "Service Unavailable",
            "The service is temporarily unavailable"));
}

# ── 504 Gateway Timeout ───────────────────────────────────────────────
# This server did not receive a timely response from an upstream service.
@ExceptionHandler(UpstreamTimeoutException.class)
public ResponseEntity<ErrorResponse> handleTimeout(UpstreamTimeoutException ex) {
    return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
        .body(ErrorResponse.of(504, "Gateway Timeout",
            "Upstream service timed out"));
}

HttpStatus Enum and Status Codes in Spring

Spring's HttpStatus enum provides named constants for every standard HTTP status code. It implements the HttpStatusCode interface and integrates with ResponseEntity, @ResponseStatus, and RestTemplate/WebClient.
Java
// ── HttpStatus enum — the complete reference ──────────────────────────
HttpStatus.OK                        // 200
HttpStatus.CREATED                   // 201
HttpStatus.ACCEPTED                  // 202
HttpStatus.NO_CONTENT                // 204
HttpStatus.PARTIAL_CONTENT           // 206
HttpStatus.MOVED_PERMANENTLY         // 301
HttpStatus.FOUND                     // 302
HttpStatus.NOT_MODIFIED              // 304
HttpStatus.BAD_REQUEST               // 400
HttpStatus.UNAUTHORIZED              // 401
HttpStatus.FORBIDDEN                 // 403
HttpStatus.NOT_FOUND                 // 404
HttpStatus.METHOD_NOT_ALLOWED        // 405
HttpStatus.CONFLICT                  // 409
HttpStatus.GONE                      // 410
HttpStatus.UNSUPPORTED_MEDIA_TYPE    // 415
HttpStatus.UNPROCESSABLE_ENTITY      // 422
HttpStatus.TOO_MANY_REQUESTS         // 429
HttpStatus.INTERNAL_SERVER_ERROR     // 500
HttpStatus.BAD_GATEWAY               // 502
HttpStatus.SERVICE_UNAVAILABLE       // 503
HttpStatus.GATEWAY_TIMEOUT           // 504

// ── HttpStatus utility methods ────────────────────────────────────────
HttpStatus status = HttpStatus.valueOf(404);          // from int
HttpStatus status = HttpStatus.resolve(999);          // null if unknown code
status.value()                                        // int: 404
status.getReasonPhrase()                              // "Not Found"
status.is2xxSuccessful()                              // true/false
status.is4xxClientError()                             // true/false
status.is5xxServerError()                             // true/false
status.isError()                                      // 4xx or 5xx

// ── @ResponseStatus on exception classes ──────────────────────────────
// Annotate a custom exception to map it to a status code automatically:
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long id) {
        super("User not found: " + id);
    }
}
// Any unhandled UserNotFoundException → 404 Not Found
// (Without a @RestControllerAdvice handler — less control over body shape)

// ── @ResponseStatus on controller methods ─────────────────────────────
// Forces a specific status on a method that returns void or a plain object:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)    // 201 Created — no ResponseEntity needed
public UserResponse create(@RequestBody @Valid CreateUserRequest request) {
    return userService.create(request);
    // Returns 201 + serialized body — but no Location header control
}

// Prefer ResponseEntity for creation — it allows the Location header.
// @ResponseStatus is convenient for simple cases where headers don't matter.

Status Code Decision Guide

A practical reference for choosing the correct status code for common REST API scenarios.
Shell
# ── GET requests ─────────────────────────────────────────────────────
# Resource found and returned          → 200 OK
# Resource not found                   → 404 Not Found
# Cached response still valid          → 304 Not Modified
# Access denied                        → 403 Forbidden (or 404 to hide existence)

# ── POST requests (create) ────────────────────────────────────────────
# Resource created                     → 201 Created + Location header
# Request accepted, processing async   → 202 Accepted + status URL
# Validation failed                    → 400 Bad Request + field errors
# Duplicate / conflict                 → 409 Conflict
# Business rule violation              → 422 Unprocessable Entity

# ── PUT requests (full update) ────────────────────────────────────────
# Resource updated, body returned      → 200 OK
# Resource updated, no body            → 204 No Content
# Resource not found                   → 404 Not Found
# Optimistic lock conflict             → 409 Conflict

# ── PATCH requests (partial update) ──────────────────────────────────
# Fields updated, body returned        → 200 OK
# Fields updated, no body              → 204 No Content
# Resource not found                   → 404 Not Found
# Invalid field value                  → 400 Bad Request

# ── DELETE requests ───────────────────────────────────────────────────
# Resource deleted                     → 204 No Content
# Resource not found                   → 404 Not Found (or 204 — be consistent)
# Resource permanently gone            → 410 Gone

# ── Auth scenarios ────────────────────────────────────────────────────
# No credentials provided              → 401 Unauthorized
# Invalid credentials                  → 401 Unauthorized
# Valid credentials, wrong permissions → 403 Forbidden
# Rate limit exceeded                  → 429 Too Many Requests

# ── Server scenarios ──────────────────────────────────────────────────
# Unhandled exception                  → 500 Internal Server Error
# Upstream service error               → 502 Bad Gateway
# Server overloaded / maintenance      → 503 Service Unavailable
# Upstream timeout                     → 504 Gateway Timeout