Skip to content
Home / Skills / Api Design / Error Handling
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.


References