AP
Error Handling
Api Design core v1.0.0
API Error Handling
Overview
Consistent error handling improves API usability and debuggability. This skill covers HTTP status codes, error response formats (RFC 7807), validation errors, and error localization.
Key Concepts
HTTP Status Code Categories
┌─────────────────────────────────────────────────────────────┐
│ HTTP Status Codes │
├─────────────────────────────────────────────────────────────┤
│ │
│ 2xx Success: │
│ ├─ 200 OK Standard success │
│ ├─ 201 Created Resource created (include Location) │
│ ├─ 202 Accepted Async processing started │
│ ├─ 204 No Content Success with no body (DELETE) │
│ └─ 206 Partial Content Range request fulfilled │
│ │
│ 4xx Client Errors: │
│ ├─ 400 Bad Request Malformed request │
│ ├─ 401 Unauthorized Authentication required/failed │
│ ├─ 403 Forbidden Authenticated but not authorized │
│ ├─ 404 Not Found Resource doesn't exist │
│ ├─ 405 Method Not Allowed HTTP method not supported │
│ ├─ 409 Conflict State conflict (duplicate, etc.) │
│ ├─ 410 Gone Resource permanently removed │
│ ├─ 422 Unprocessable Validation error │
│ └─ 429 Too Many Requests Rate limit exceeded │
│ │
│ 5xx Server Errors: │
│ ├─ 500 Internal Server Error Unexpected error │
│ ├─ 502 Bad Gateway Upstream service error │
│ ├─ 503 Service Unavailable Temporary unavailability │
│ └─ 504 Gateway Timeout Upstream timeout │
│ │
│ RFC 7807 Problem Details: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "type": "https://api.example.com/errors/...", │ │
│ │ "title": "Validation Error", │ │
│ │ "status": 422, │ │
│ │ "detail": "Request failed validation", │ │
│ │ "instance": "/orders/abc123", │ │
│ │ "errors": [...] // Extension │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Best Practices
1. Use Appropriate Status Codes
Match HTTP semantics to error types.
2. Follow RFC 7807 Problem Details
Standard format for HTTP API errors.
3. Include Actionable Information
Help clients fix the issue.
4. Don’t Expose Internal Details
Hide stack traces and implementation details.
5. Correlate with Request ID
Enable tracing across systems.
Code Examples
Example 1: RFC 7807 Problem Details
@Data
@Builder
public class ProblemDetail {
// RFC 7807 standard fields
private String type; // URI identifying error type
private String title; // Short, human-readable summary
private Integer status; // HTTP status code
private String detail; // Human-readable explanation
private String instance; // URI of specific occurrence
// Extensions
private String errorCode; // Machine-readable code
private Instant timestamp;
private String traceId;
private List<ValidationError> errors; // For validation failures
public static ProblemDetail of(HttpStatus status, String detail) {
return ProblemDetail.builder()
.type("https://api.example.com/errors/" + status.name().toLowerCase())
.title(status.getReasonPhrase())
.status(status.value())
.detail(detail)
.timestamp(Instant.now())
.build();
}
public ProblemDetail withTraceId(String traceId) {
this.traceId = traceId;
return this;
}
public ProblemDetail withInstance(String path) {
this.instance = path;
return this;
}
}
@Data
@AllArgsConstructor
class ValidationError {
private String field;
private String message;
private Object rejectedValue;
private String code;
}
Example 2: Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final String ERRORS_BASE_URI = "https://api.example.com/errors/";
/**
* Handle validation errors
*/
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
List<ValidationError> validationErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new ValidationError(
error.getField(),
error.getDefaultMessage(),
error.getRejectedValue(),
error.getCode()
))
.toList();
ProblemDetail problem = ProblemDetail.builder()
.type(ERRORS_BASE_URI + "validation-error")
.title("Validation Failed")
.status(HttpStatus.UNPROCESSABLE_ENTITY.value())
.detail("Request validation failed with " + validationErrors.size() + " errors")
.instance(((ServletWebRequest) request).getRequest().getRequestURI())
.errors(validationErrors)
.timestamp(Instant.now())
.traceId(MDC.get("traceId"))
.build();
return ResponseEntity
.status(HttpStatus.UNPROCESSABLE_ENTITY)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
/**
* Handle resource not found
*/
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(
ResourceNotFoundException ex,
WebRequest request) {
ProblemDetail problem = ProblemDetail.builder()
.type(ERRORS_BASE_URI + "resource-not-found")
.title("Resource Not Found")
.status(HttpStatus.NOT_FOUND.value())
.detail(ex.getMessage())
.instance(((ServletWebRequest) request).getRequest().getRequestURI())
.errorCode("RESOURCE_NOT_FOUND")
.timestamp(Instant.now())
.traceId(MDC.get("traceId"))
.build();
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
/**
* Handle business rule violations
*/
@ExceptionHandler(BusinessRuleException.class)
public ResponseEntity<ProblemDetail> handleBusinessRule(
BusinessRuleException ex,
WebRequest request) {
ProblemDetail problem = ProblemDetail.builder()
.type(ERRORS_BASE_URI + "business-rule-violation")
.title("Business Rule Violation")
.status(HttpStatus.CONFLICT.value())
.detail(ex.getMessage())
.instance(((ServletWebRequest) request).getRequest().getRequestURI())
.errorCode(ex.getErrorCode())
.timestamp(Instant.now())
.traceId(MDC.get("traceId"))
.build();
return ResponseEntity
.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
/**
* Handle rate limiting
*/
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ProblemDetail> handleRateLimit(
RateLimitExceededException ex,
WebRequest request) {
ProblemDetail problem = ProblemDetail.builder()
.type(ERRORS_BASE_URI + "rate-limit-exceeded")
.title("Too Many Requests")
.status(HttpStatus.TOO_MANY_REQUESTS.value())
.detail("Rate limit exceeded. Try again in " + ex.getRetryAfterSeconds() + " seconds")
.instance(((ServletWebRequest) request).getRequest().getRequestURI())
.errorCode("RATE_LIMIT_EXCEEDED")
.timestamp(Instant.now())
.traceId(MDC.get("traceId"))
.build();
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()))
.header("X-RateLimit-Limit", String.valueOf(ex.getLimit()))
.header("X-RateLimit-Remaining", "0")
.body(problem);
}
/**
* Handle unexpected errors
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleGenericException(
Exception ex,
WebRequest request) {
// Log full exception internally
log.error("Unexpected error processing request", ex);
// Don't expose internal details to client
ProblemDetail problem = ProblemDetail.builder()
.type(ERRORS_BASE_URI + "internal-error")
.title("Internal Server Error")
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.detail("An unexpected error occurred. Please try again or contact support.")
.instance(((ServletWebRequest) request).getRequest().getRequestURI())
.errorCode("INTERNAL_ERROR")
.timestamp(Instant.now())
.traceId(MDC.get("traceId"))
.build();
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
Example 3: Custom Exception Hierarchy
// Base exception with error code
public abstract class ApiException extends RuntimeException {
private final String errorCode;
private final HttpStatus httpStatus;
private final Map<String, Object> metadata = new HashMap<>();
protected ApiException(String message, String errorCode, HttpStatus httpStatus) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
public ApiException with(String key, Object value) {
metadata.put(key, value);
return this;
}
public String getErrorCode() { return errorCode; }
public HttpStatus getHttpStatus() { return httpStatus; }
public Map<String, Object> getMetadata() { return metadata; }
}
// Specific exceptions
public class ResourceNotFoundException extends ApiException {
public ResourceNotFoundException(String resourceType, Object id) {
super(
resourceType + " with id '" + id + "' not found",
"RESOURCE_NOT_FOUND",
HttpStatus.NOT_FOUND
);
with("resourceType", resourceType);
with("resourceId", id);
}
}
public class ValidationException extends ApiException {
private final List<ValidationError> errors;
public ValidationException(List<ValidationError> errors) {
super(
"Validation failed with " + errors.size() + " errors",
"VALIDATION_FAILED",
HttpStatus.UNPROCESSABLE_ENTITY
);
this.errors = errors;
}
public List<ValidationError> getErrors() { return errors; }
}
public class ConflictException extends ApiException {
public ConflictException(String message) {
super(message, "CONFLICT", HttpStatus.CONFLICT);
}
public static ConflictException duplicateResource(String type, String field, Object value) {
return (ConflictException) new ConflictException(
type + " with " + field + " '" + value + "' already exists"
)
.with("resourceType", type)
.with("field", field)
.with("value", value);
}
}
public class AuthorizationException extends ApiException {
public AuthorizationException(String message) {
super(message, "FORBIDDEN", HttpStatus.FORBIDDEN);
}
public static AuthorizationException insufficientPermissions(String action, String resource) {
return (AuthorizationException) new AuthorizationException(
"Insufficient permissions to " + action + " " + resource
)
.with("action", action)
.with("resource", resource);
}
}
Example 4: Validation Error Details
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.create(request);
return ResponseEntity.created(URI.create("/api/users/" + user.getId()))
.body(UserResponse.from(user));
}
}
@Data
class CreateUserRequest {
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Password must contain uppercase, lowercase, and number"
)
private String password;
@NotBlank(message = "First name is required")
@Size(max = 50, message = "First name must not exceed 50 characters")
private String firstName;
@Size(max = 50, message = "Last name must not exceed 50 characters")
private String lastName;
@Past(message = "Birth date must be in the past")
private LocalDate birthDate;
}
// Cross-field validation
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface PasswordMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, RegistrationRequest> {
@Override
public boolean isValid(RegistrationRequest request, ConstraintValidatorContext context) {
if (request.getPassword() == null || request.getConfirmPassword() == null) {
return true; // Let @NotNull handle null checks
}
boolean valid = request.getPassword().equals(request.getConfirmPassword());
if (!valid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("Passwords do not match")
.addPropertyNode("confirmPassword")
.addConstraintViolation();
}
return valid;
}
}
// Service-level validation with custom errors
@Service
public class UserService {
public User create(CreateUserRequest request) {
// Business validation
List<ValidationError> errors = new ArrayList<>();
if (userRepository.existsByEmail(request.getEmail())) {
errors.add(new ValidationError(
"email",
"Email address is already registered",
request.getEmail(),
"DUPLICATE_EMAIL"
));
}
if (isDisposableEmail(request.getEmail())) {
errors.add(new ValidationError(
"email",
"Disposable email addresses are not allowed",
request.getEmail(),
"DISPOSABLE_EMAIL"
));
}
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
// Create user...
}
}
Example 5: Localized Error Messages
@Component
public class LocalizedErrorMessageResolver {
private final MessageSource messageSource;
public String resolve(String code, Object[] args, Locale locale) {
try {
return messageSource.getMessage(code, args, locale);
} catch (NoSuchMessageException e) {
return code; // Fallback to code
}
}
public ProblemDetail localize(ProblemDetail problem, Locale locale) {
// Localize title
String localizedTitle = resolve("error." + problem.getErrorCode() + ".title", null, locale);
problem.setTitle(localizedTitle);
// Localize detail if it's a message code
if (problem.getDetail() != null && problem.getDetail().startsWith("error.")) {
String localizedDetail = resolve(problem.getDetail(), null, locale);
problem.setDetail(localizedDetail);
}
// Localize validation errors
if (problem.getErrors() != null) {
problem.getErrors().forEach(error -> {
String messageCode = "validation." + error.getCode();
String localizedMessage = resolve(messageCode, new Object[]{error.getField()}, locale);
error.setMessage(localizedMessage);
});
}
return problem;
}
}
// messages.properties
// error.RESOURCE_NOT_FOUND.title=Resource Not Found
// error.VALIDATION_FAILED.title=Validation Failed
// validation.NotBlank={0} is required
// validation.Email={0} must be a valid email address
// messages_es.properties
// error.RESOURCE_NOT_FOUND.title=Recurso No Encontrado
// error.VALIDATION_FAILED.title=Validación Fallida
// validation.NotBlank={0} es requerido
// validation.Email={0} debe ser una dirección de correo válida
@RestControllerAdvice
public class LocalizedExceptionHandler {
private final LocalizedErrorMessageResolver messageResolver;
@ExceptionHandler(ApiException.class)
public ResponseEntity<ProblemDetail> handleApiException(
ApiException ex,
WebRequest request,
Locale locale) {
ProblemDetail problem = ProblemDetail.builder()
.type(ERRORS_BASE_URI + ex.getErrorCode().toLowerCase().replace("_", "-"))
.title(ex.getMessage())
.status(ex.getHttpStatus().value())
.detail(ex.getMessage())
.errorCode(ex.getErrorCode())
.timestamp(Instant.now())
.traceId(MDC.get("traceId"))
.build();
// Localize based on Accept-Language
ProblemDetail localized = messageResolver.localize(problem, locale);
return ResponseEntity
.status(ex.getHttpStatus())
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(localized);
}
}
Anti-Patterns
❌ Generic Error Messages
// WRONG - not helpful
{"error": "Something went wrong"}
// ✅ CORRECT - actionable
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"detail": "The email field is invalid",
"errors": [{"field": "email", "message": "Invalid format"}]
}
❌ Exposing Stack Traces
Never expose internal implementation details to clients.