Fa quatre lliçons que anem deixant etiquetes TODO pel camí. Els 404 es construeixen a mà a cada controlador amb ResponseEntity.notFound(). Les regles de negoci de 03-03 llancen IllegalStateException que es converteixen en errors del servidor amb la traça de pila a dins. La ConstraintViolationException de 03-04 surt com a 500 quan la culpa és del client. I els errors de validació responen "Invalid request content." sense dir quin camp ha fallat. Tot això se salda en aquesta lliçó. Construirem la jerarquia d'excepcions de CicloUrbana, la centralitzarem en un únic gestor global, adoptarem el format estàndard RFC 7807 Problem Details que Spring Boot 3 suporta de manera nativa, i ens assegurarem que cap error no filtri informació que no ha de sortir del servidor.
Contingut
- El comportament per defecte de Spring Boot
- Per què no serveix per a una API pública
- La jerarquia d'excepcions de CicloUrbana
@ResponseStatusa l'excepció i els seus límits@ExceptionHandlerlocal al controlador@RestControllerAdvice: el gestor global- RFC 7807: Problem Details
- Errors de validació amb la llista de camps
- Excepcions del framework
- No filtrar informació sensible
- Què registrar al log segons la gravetat
- Identificador de rastreig a la resposta
- Errors Comuns i Consells
- Exercicis
- El comportament per defecte de Spring Boot
Spring Boot no deixa mai una excepció sense resposta. Quan una s'escapa del controlador, el DispatcherServlet —el recorregut del qual vam dibuixar a 03-01— la passa per una cadena de HandlerExceptionResolver. Si cap no la reconeix, la petició es reenvia internament a /error, atès pel BasicErrorController. Si hi afegeixes un endpoint de prova que llança IllegalStateException, la resposta és aquesta:
{ "timestamp": "2026-09-01T09:14:22.481+00:00", "status": 500,
"error": "Internal Server Error", "path": "/api/v1/estacions/prova-error" }Aquest format es controla amb quatre propietats:
server:
error:
include-message: always # never | always | on-param (per defecte: never)
include-binding-errors: always # errors de validació a la resposta
include-stacktrace: never # MAI en producció
include-exception: false # nom de la classe de l'excepció
path: /error # ruta interna del BasicErrorController| Propietat | Per defecte | Risc si s'activa |
|---|---|---|
include-message |
never |
Pot exposar detalls interns del missatge |
include-binding-errors |
never |
Baix: són dades que el client ha enviat |
include-stacktrace |
never |
Alt: revela classes, rutes i versions |
include-exception |
false |
Mitjà: revela la implementació interna |
Amb include-message i include-stacktrace a always, la resposta passa a incloure el missatge complet i la traça de pila sencera: noms de paquet, versions de llibreries, línies de codi. És informació d'or per a qui busqui vulnerabilitats. include-stacktrace ha de ser never en producció, sense excepcions.
- Per què no serveix per a una API pública
Encara que afinem aquestes propietats, la gestió per defecte té quatre problemes que no s'arreglen amb configuració. Tot és 500: IllegalStateException, IllegalArgumentException i NoSuchElementException surten igual, encara que unes siguin culpa del client i altres nostres, i això arruïna el monitoratge —el sistema d'alertes del mòdul 9 despertarà algú de matinada per un 404—. El client no pot programar contra l'error, perquè no hi ha un codi estable que distingir, només un text en anglès que pot canviar en qualsevol versió. No hi ha context útil: "Bad Request" no diu quin camp estava malament. I el format no és un estàndard, així que cada client escriu el seu propi analitzador.
El que necessita una API pública és que cada tipus de fallada tingui un codi HTTP correcte, un identificador estable, un missatge útil i un format uniforme. Això és el que construirem.
- La jerarquia d'excepcions de CicloUrbana
Tota excepció del domini hereta d'una base comuna, a com.ciclourbana.comu.excepcions:
/**
* Base de totes les excepcions de negoci de CicloUrbana.
*
* És RuntimeException (no comprovada) deliberadament: obligar a declarar
* throws a cada signatura contamina el codi sense aportar seguretat, perquè
* ningú no es pot recuperar d'aquestes fallades llevat del gestor global.
*/
public abstract class CicloUrbanaException extends RuntimeException {
/** Codi estable que el client pot fer servir a la seva lògica. */
private final String codi;
protected CicloUrbanaException(String codi, String missatge) {
super(missatge);
this.codi = codi;
}
public String getCodi() {
return codi;
}
}El camp codi és el que fa l'API programable: el client reacciona a ESTACIO_PLENA sense dependre del text del missatge, que es pot traduir o reescriure sense trencar res. Les excepcions concretes:
/** El recurs sol·licitat no existeix. Mapeja a 404. */
public class RecursNoTrobatException extends CicloUrbanaException {
public RecursNoTrobatException(String tipusRecurs, Object id) {
super("RECURS_NO_TROBAT",
"No s'ha trobat %s amb identificador %s".formatted(tipusRecurs, id));
}
}
/** Regla de negoci violada (422) i conflicte amb un altre recurs (409). */
public class ReglaNegociException extends CicloUrbanaException {
public ReglaNegociException(String codi, String missatge) { super(codi, missatge); }
}
public class ConflicteRecursException extends CicloUrbanaException {
public ConflicteRecursException(String codi, String missatge) { super(codi, missatge); }
}
/** Especialitzacions amb el missatge construït. */
public class EstacioPlenaException extends ConflicteRecursException {
public EstacioPlenaException(String nom, int capacitat) {
super("ESTACIO_PLENA", "L'estació %s no té ancoratges lliures (capacitat %d)"
.formatted(nom, capacitat));
}
}
public class BicicletaNoDisponibleException extends ReglaNegociException {
public BicicletaNoDisponibleException(String matricula, EstatBicicleta estat) {
super("BICICLETA_NO_DISPONIBLE",
"La bicicleta %s no està disponible (estat: %s)".formatted(matricula, estat));
}
}El mapatge complet a codis HTTP:
| Excepció | HTTP | codi |
Quan es llança |
|---|---|---|---|
RecursNoTrobatException |
404 |
RECURS_NO_TROBAT |
Estació o lloguer inexistent |
ConflicteRecursException |
409 |
variable | Nom d'estació duplicat |
EstacioPlenaException |
409 |
ESTACIO_PLENA |
Retornar a una estació plena |
ReglaNegociException |
422 |
variable | Lloguer ja finalitzat |
BicicletaNoDisponibleException |
422 |
BICICLETA_NO_DISPONIBLE |
Bicicleta en manteniment |
MethodArgumentNotValidException |
400 |
VALIDACIO |
@Valid falla al cos |
ConstraintViolationException |
400 |
VALIDACIO |
@Validated falla en un paràmetre |
HttpMessageNotReadableException |
400 |
COS_ILLEGIBLE |
JSON mal format |
| Qualsevol altra | 500 |
ERROR_INTERN |
Fallada no prevista |
Amb això, els serveis deixen de llançar IllegalStateException:
// A LloguerService, substituint el codi provisional de 03-03
Lloguer lloguer = lloguerRepositori.cercarPerId(lloguerId)
.orElseThrow(() -> new RecursNoTrobatException("lloguer", lloguerId));
if (lloguer.estat() == EstatLloguer.FINALITZAT) {
throw new ReglaNegociException("LLOGUER_JA_FINALITZAT",
"El lloguer %d ja estava finalitzat".formatted(lloguerId));
}
Estacio desti = estacioRepositori.cercarPerId(estacioDestiId)
.orElseThrow(() -> new RecursNoTrobatException("estació", estacioDestiId));
if (bicicletaRepositori.comptarPerEstacio(desti.id()) >= desti.capacitat()) {
throw new EstacioPlenaException(desti.nom(), desti.capacitat());
}Fixa't en el patró orElseThrow: converteix un Optional buit en l'excepció adequada en una sola expressió, i és la raó per la qual els repositoris retornen Optional i no null.
@ResponseStatus a l'excepció i els seus límits
@ResponseStatus a l'excepció i els seus límitsLa manera més ràpida d'associar un codi HTTP a una excepció és anotar-la amb @ResponseStatus(HttpStatus.NOT_FOUND). Spring la detecta amb ResponseStatusExceptionResolver i respon 404. Funciona, és una línia, i té tres límits seriosos:
| Límit | Conseqüència |
|---|---|
| L'estat és fix | La mateixa excepció no pot donar 409 en un cas i 422 en un altre |
| No controla el cos | La resposta la continua generant BasicErrorController |
| Només serveix per a excepcions pròpies | No pots anotar ConstraintViolationException, d'una llibreria |
Per aquests motius, CicloUrbana no fa servir @ResponseStatus a les seves excepcions: el mapatge complet viu al gestor global, en un sol lloc que es llegeix d'un cop d'ull.
Existeix una variant interessant, ResponseStatusException, que duu l'estat a dins: throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Estació no trobada"). És còmoda per a prototips, però acobla la capa de servei a l'API web —HttpStatus és una classe de Spring Web— i no permet el camp codi. El curs l'esmenta i no l'adopta.
@ExceptionHandler local al controlador
@ExceptionHandler local al controladorUn mètode anotat amb @ExceptionHandler dins d'un controlador captura les excepcions d'aquell controlador:
// Dins d'EstacioController, al costat dels endpoints
@ExceptionHandler(EstacioPlenaException.class)
public ProblemDetail gestionarEstacioPlena(EstacioPlenaException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}El seu abast limitat és alhora la seva virtut i el seu defecte: serveix per a una excepció específica d'un controlador concret, però repetir-ho a cada classe multiplica el codi, així que CicloUrbana fa servir el gestor global i reserva els locals per a casos genuïnament particulars. Una dada de precedència que convé retenir: el gestor local sempre guanya al global quan tots dos capturen la mateixa excepció.
@RestControllerAdvice: el gestor global
@RestControllerAdvice: el gestor global@RestControllerAdvice és @ControllerAdvice + @ResponseBody: una classe els @ExceptionHandler de la qual s'apliquen a tots els controladors.
package com.ciclourbana.comu;
@RestControllerAdvice
public class GestorGlobalExcepcions {
private static final Logger log = LoggerFactory.getLogger(GestorGlobalExcepcions.class);
private static final URI BASE_TIPUS = URI.create("https://api.ciclourbana.example/errors/");
@ExceptionHandler(RecursNoTrobatException.class)
public ProblemDetail gestionarNoTrobat(RecursNoTrobatException e,
HttpServletRequest peticio) {
log.info("Recurs no trobat a {}: {}", peticio.getRequestURI(), e.getMessage());
return construir(HttpStatus.NOT_FOUND, "Recurs no trobat", e);
}
@ExceptionHandler(ConflicteRecursException.class)
public ProblemDetail gestionarConflicte(ConflicteRecursException e,
HttpServletRequest peticio) {
log.warn("Conflicte a {}: {}", peticio.getRequestURI(), e.getMessage());
return construir(HttpStatus.CONFLICT, "Conflicte amb l'estat actual", e);
}
@ExceptionHandler(ReglaNegociException.class)
public ProblemDetail gestionarReglaNegoci(ReglaNegociException e,
HttpServletRequest peticio) {
log.warn("Regla violada a {}: {}", peticio.getRequestURI(), e.getMessage());
return construir(HttpStatus.UNPROCESSABLE_ENTITY, "Operació no permesa", e);
}
/** Xarxa de seguretat. Si es dispara, és un bug: per això ERROR amb la traça. */
@ExceptionHandler(Exception.class)
public ProblemDetail gestionarInesperat(Exception e, HttpServletRequest peticio) {
String idRastre = UUID.randomUUID().toString();
log.error("Error inesperat [rastre={}] a {}", idRastre, peticio.getRequestURI(), e);
ProblemDetail problema = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
// Missatge GENÈRIC: no es filtra e.getMessage()
"S'ha produït un error intern. Contacta amb suport indicant el rastre.");
problema.setTitle("Error intern");
problema.setType(BASE_TIPUS.resolve("error-intern"));
problema.setProperty("codi", "ERROR_INTERN");
problema.setProperty("rastre", idRastre);
return problema;
}
/** Construcció comuna de la resposta Problem Details. */
private ProblemDetail construir(HttpStatus estat, String titol, CicloUrbanaException e) {
ProblemDetail problema = ProblemDetail.forStatusAndDetail(estat, e.getMessage());
problema.setTitle(titol);
problema.setType(BASE_TIPUS.resolve(e.getCodi().toLowerCase().replace('_', '-')));
problema.setProperty("codi", e.getCodi());
problema.setProperty("marcaTemps", Instant.now().toString());
return problema;
}
}Tres detalls importants. Retornar ProblemDetail directament n'hi ha prou: Spring el reconeix, fixa l'estat a partir del camp status i posa Content-Type: application/problem+json, sense necessitat de ResponseEntity. El gestor d'Exception no filtra e.getMessage(): el missatge real va al log i al client només hi arriba un text genèric amb un identificador de rastreig (apartat 10). I l'ordre de resolució és per especificitat del tipus, no per ordre de declaració: si es llança EstacioPlenaException, Spring tria el gestor de ConflicteRecursException —la seva superclasse més propera amb gestor— i no el d'Exception, que per això es pot declarar sense por que es mengi els altres.
Quan hi ha diversos @RestControllerAdvice —per exemple, un per al domini i un altre que aporti Spring Security al mòdul 5—, el desempat sí que depèn de l'ordre, i es controla amb @Order: @Order(Ordered.HIGHEST_PRECEDENCE) al gestor de seguretat i @Order(Ordered.LOWEST_PRECEDENCE) al general, que actua de xarxa de seguretat. @RestControllerAdvice accepta a més basePackages, assignableTypes o annotations per limitar-ne l'abast, útil si conviuen una API pública i un tauler intern amb formats d'error diferents.
Amb el gestor en marxa, els controladors se simplifiquen. L'obtenirPerId de 03-02 perd el seu ResponseEntity i el seu .map(...).orElseGet(...):
@GetMapping("/{id:\\d+}")
public EstacioDetallResponse obtenirPerId(@PathVariable("id") @Positive Long id) {
Estacio estacio = estacioService.cercarPerId(id)
.orElseThrow(() -> new RecursNoTrobatException("estació", id));
return estacioMapper.aDetall(estacio, bicicletaService.cercarPerEstacio(id));
}El TODO que hi vam deixar queda saldat: el camí d'error ja no ocupa espai al camí feliç.
- RFC 7807: Problem Details
L'RFC 7807 —actualitzat per l'RFC 9457— defineix un format estàndard per als errors HTTP, i Spring Boot 3 el suporta de manera nativa amb la classe ProblemDetail. Els camps de l'estàndard:
| Camp | Significat |
|---|---|
type |
URI que identifica el tipus de problema; serveix de documentació |
title |
Resum llegible i estable per a aquest tipus |
status |
El codi HTTP, repetit al cos |
detail |
Explicació específica d'aquesta ocurrència |
instance |
URI de l'ocurrència concreta |
| (extensions) | Camps propis: codi, rastre, errors... |
La distinció entre title i detail és la que més es confon: title és fix per al tipus de problema ("Recurs no trobat") i detail canvia a cada ocurrència ("No s'ha trobat estació amb identificador 999"). Un client pot agrupar errors per type i mostrar detail a l'usuari.
La resposta que produeix el nostre gestor davant de GET /api/v1/estacions/999 és un 404 amb Content-Type: application/problem+json i aquest cos:
{ "type": "https://api.ciclourbana.example/errors/recurs-no-trobat",
"title": "Recurs no trobat", "status": 404,
"detail": "No s'ha trobat estació amb identificador 999",
"instance": "/api/v1/estacions/999",
"codi": "RECURS_NO_TROBAT", "marcaTemps": "2026-09-01T09:22:41.117Z" }Content-Type: application/problem+json el posa Spring sol en detectar un ProblemDetail. És el tipus MIME que vam anunciar a la taula de 03-01.
Spring Boot ofereix a més una activació global sense escriure gestors:
spring:
mvc:
problemdetails:
enabled: true # les excepcions de Spring MVC surten com a Problem DetailsFa que les excepcions del framework mateix —MethodArgumentNotValidException, HttpRequestMethodNotSupportedException, NoResourceFoundException— produeixin Problem Details en lloc del format de BasicErrorController. No cobreix les pròpies, per a les quals continua calent el gestor, però dona coherència als errors que no gestiones explícitament.
Existeixen a més ErrorResponseException, una excepció de Spring que ja duu un ProblemDetail a dins i permet llançar un error completament format des de qualsevol punt, i ResponseEntityExceptionHandler, una classe base amb gestors preescrits per a totes les excepcions de Spring MVC que pots estendre i sobreescriure mètode a mètode: dona més cobertura de sortida a canvi de menys control.
- Errors de validació amb la llista de camps
Recuperem el deute de 03-04: una MethodArgumentNotValidException conté un BindingResult amb tots els camps que han fallat, i cal extreure'ls i posar-los a la resposta.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail gestionarValidacioCos(MethodArgumentNotValidException e) {
// Un camp pot tenir diversos errors: s'agrupen per nom de camp
Map<String, List<String>> errors = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.groupingBy(FieldError::getField, LinkedHashMap::new,
Collectors.mapping(DefaultMessageSourceResolvable::getDefaultMessage,
Collectors.toList())));
// Errors de restriccions de classe (@CoordenadesValides sense camp associat)
List<String> globals = e.getBindingResult().getGlobalErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage).toList();
ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"La petició conté %d camp(s) amb errors".formatted(errors.size()));
problema.setTitle("Error de validació");
problema.setType(BASE_TIPUS.resolve("validacio"));
problema.setProperty("codi", "VALIDACIO");
problema.setProperty("errors", errors);
if (!globals.isEmpty()) {
problema.setProperty("errorsGlobals", globals);
}
return problema;
}
/** @Validated sobre paràmetres: excepció diferent, mateixa forma de resposta. */
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail gestionarValidacioParametres(ConstraintViolationException e) {
Map<String, List<String>> errors = e.getConstraintViolations().stream()
.collect(Collectors.groupingBy(v -> ultimNode(v.getPropertyPath()),
LinkedHashMap::new,
Collectors.mapping(ConstraintViolation::getMessage, Collectors.toList())));
ProblemDetail problema = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Paràmetres de la petició no vàlids");
problema.setTitle("Error de validació");
problema.setType(BASE_TIPUS.resolve("validacio"));
problema.setProperty("codi", "VALIDACIO");
problema.setProperty("errors", errors);
return problema;
}
/** El path arriba com a "llistar.mida"; al client només li interessa "mida". */
private String ultimNode(Path ruta) {
String complet = ruta.toString();
return complet.substring(complet.lastIndexOf('.') + 1);
}El resultat, amb la mateixa petició que a 03-04 retornava "Invalid request content.":
{ "type": "https://api.ciclourbana.example/errors/validacio",
"title": "Error de validació", "status": 400,
"detail": "La petició conté 2 camp(s) amb errors",
"instance": "/api/v1/estacions", "codi": "VALIDACIO",
"errors": {
"nom": ["El nom de l'estació és obligatori",
"El nom ha de tenir entre 3 i 80 caràcters"],
"capacitat": ["La capacitat ha de ser més gran que zero"] } }Ara el tauler de Ribalta pot ressaltar els dos camps i mostrar els seus missatges al costat de cadascun. Observa que nom acumula dos errors: l'agrupació en List<String> no en perd cap, mentre que un Map<String, String> n'hauria descartat un en silenci. I amb això se salda també el parany del 500 de 03-04: ConstraintViolationException ja respon 400.
- Excepcions del framework
A més de les pròpies, cal gestionar les que llança Spring quan la petició està mal formada:
| Excepció | Causa | Estat |
|---|---|---|
HttpMessageNotReadableException |
JSON mal format o cos absent | 400 |
MethodArgumentTypeMismatchException |
/estacions/abc amb id de tipus Long |
400 |
MissingServletRequestParameterException |
Falta un @RequestParam obligatori |
400 |
HttpRequestMethodNotSupportedException |
DELETE sobre la col·lecció |
405 |
HttpMediaTypeNotSupportedException |
S'envia XML on s'espera JSON | 415 |
HttpMediaTypeNotAcceptableException |
Accept que no podem satisfer |
406 |
NoResourceFoundException |
Ruta inexistent (Spring Boot 3.2+) | 404 |
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail gestionarCosIllegible(HttpMessageNotReadableException e) {
// COMPTE: e.getMessage() inclou un fragment del JSON rebut i la classe
// Java destí. És informació interna: no es retorna al client.
log.warn("Cos il·legible: {}", e.getMessage());
ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"El cos de la petició no és un JSON vàlid");
problema.setTitle("Cos il·legible");
problema.setType(BASE_TIPUS.resolve("cos-illegible"));
problema.setProperty("codi", "COS_ILLEGIBLE");
return problema;
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail gestionarTipusIncorrecte(MethodArgumentTypeMismatchException e) {
String tipusEsperat = e.getRequiredType() != null
? e.getRequiredType().getSimpleName() : "desconegut";
ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"El paràmetre '%s' ha de ser de tipus %s".formatted(e.getName(), tipusEsperat));
problema.setTitle("Tipus de paràmetre incorrecte");
problema.setProperty("codi", "TIPUS_INCORRECTE");
problema.setProperty("parametre", e.getName());
return problema;
}NoResourceFoundException (amb spring.mvc.throw-exception-if-no-handler-found: true) i HttpRequestMethodNotSupportedException segueixen el mateix patró; l'exercici 3 completa aquesta última amb la capçalera Allow. Ara aquella petició del .http de 03-02 que responia un 400 incomprensible dona alguna cosa útil davant de ?capacitatMinima=molt:
{ "type": "about:blank", "title": "Tipus de paràmetre incorrecte", "status": 400,
"detail": "El paràmetre 'capacitatMinima' ha de ser de tipus Integer",
"codi": "TIPUS_INCORRECTE", "parametre": "capacitatMinima" }
- No filtrar informació sensible
Un gestor d'errors és una superfície d'atac: cada dada que retorna és una dada que algú pot fer servir contra tu. Què no ha de sortir mai:
| Dada | Per què és perillosa |
|---|---|
| Traça de pila | Revela classes, versions de llibreries i estructura del codi |
| Missatges de la base de dades | Filtren taules i columnes; ajuden a injeccions SQL |
| Rutes del sistema de fitxers | Revelen el sistema operatiu i el desplegament |
| Noms de classe Java | com.ciclourbana.passarela.ClientRedsys diu què fas servir |
| Adreces internes i ports | Faciliten el moviment lateral a la xarxa |
| El valor rebutjat per una validació | Pot ser la contrasenya acabada d'escriure |
Els tres primers arriben gairebé sempre per la mateixa via: retornar e.getMessage() d'una excepció que no controles. Amb ProblemDetail.forStatusAndDetail(INTERNAL_SERVER_ERROR, e.getMessage()) i una fallada de base de dades, l'API respon alguna cosa com "could not execute statement; SQL [insert into estacions ...]; constraint [uk_estacions_nom]", que revela l'esquema complet. El gestor d'Exception de l'apartat 6 fa el correcte: missatge genèric a fora, detall complet al log, i un identificador de rastreig que uneix tots dos.
La regla: el missatge de les excepcions pròpies pot sortir, perquè l'hem escrit nosaltres pensant en el client; el de les alienes, mai.
Un cas subtil: l'enumeració de recursos. Si GET /api/v1/usuaris/1 respon 404 i GET /api/v1/usuaris/2 respon 403, un atacant dedueix que l'usuari 2 existeix; per a recursos sensibles el correcte és respondre 404 en tots dos casos. No s'aplica a les estacions de Ribalta, que són informació pública, però sí als usuaris, i ho reprendrem al mòdul 5.
- Què registrar al log segons la gravetat
Registrar-ho tot amb log.error inutilitza el log: quan tot és un error, res no ho és.
| Situació | Nivell | Traça | Raó |
|---|---|---|---|
404 de recurs |
INFO |
No | Funcionament normal |
| Validació fallida | INFO |
No | El client s'ha equivocat; és esperable |
409 / 422 de negoci |
WARN |
No | Esperat, però pot assenyalar un client amb errors |
401 / 403 |
WARN |
No | Pot ser un intent d'accés indegut |
| Dependència externa caiguda | ERROR |
Sí | Requereix intervenció |
| Excepció no prevista | ERROR |
Sí | És un bug: cal arreglar-lo |
Dues regles pràctiques. La traça de pila només a ERROR: un 404 amb vint línies de traça multiplica la mida del log sense aportar res. I registrar l'excepció com a últim argument, no concatenada:
log.error("Error inesperat [rastre={}] a {}", idRastre, uri, e); // BÉ: traça completa
log.error("Error inesperat: " + e.getMessage()); // MALAMENT: perd la causaSLF4J tracta l'últim argument Throwable de manera especial i imprimeix la traça completa, incloses les causes encadenades; concatenar perd exactament la informació que es necessita per diagnosticar. Els logs en profunditat arriben a 09-05.
- Identificador de rastreig a la resposta
Quan un operari de Ribalta truca dient "m'ha donat un error en finalitzar el lloguer", la pregunta és quin dels centenars d'errors del log és el seu. L'identificador de rastreig respon a això: un valor únic que apareix alhora a la resposta del client i a la línia del log. La versió artesanal és la que ja tenim: un UUID.randomUUID() al gestor d'Exception. Només cobreix els errors i no relaciona entre si les línies de log d'una mateixa petició. La versió bona fa servir l'MDC (Mapped Diagnostic Context) d'SLF4J, que associa dades al fil actual i fa que totes les línies de log d'una petició duguin el mateix identificador:
package com.ciclourbana.comu;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // abans que qualsevol altre filtre
public class FiltreRastreig extends OncePerRequestFilter {
public static final String CLAU_MDC = "rastreId";
private static final String CAPCALERA = "X-Rastre-Id";
@Override
protected void doFilterInternal(HttpServletRequest peticio,
HttpServletResponse resposta,
FilterChain cadena) throws ServletException, IOException {
// Si un servei anterior ja ha enviat un identificador, es reutilitza:
// així el rastre sobreviu entre serveis (es completa a 09-06)
String rastre = Optional.ofNullable(peticio.getHeader(CAPCALERA))
.filter(s -> !s.isBlank())
.orElseGet(() -> UUID.randomUUID().toString().substring(0, 8));
MDC.put(CLAU_MDC, rastre);
resposta.setHeader(CAPCALERA, rastre); // el client sempre el rep
try {
cadena.doFilter(peticio, resposta);
} finally {
// IMPRESCINDIBLE: els fils de Tomcat es reutilitzen. Sense aquest
// remove, la petició següent heretaria el rastre de l'anterior.
MDC.remove(CLAU_MDC);
}
}
}Aquest MDC.remove al finally no és opcional: sense ell, una petició que falli deixa el seu identificador enganxat al fil i la petició següent atesa per aquest fil escriu al log un rastre que no li correspon. És un error difícil de diagnosticar precisament perquè corromp l'eina de diagnòstic.
S'inclou al patró de log amb %X{rastreId:-sense-rastre} —%X llegeix l'MDC i :-sense-rastre és el valor per defecte—, i al gestor n'hi ha prou amb problema.setProperty("rastre", MDC.get(FiltreRastreig.CLAU_MDC)):
logging:
pattern:
console: "%d{HH:mm:ss.SSS} %-5level [%X{rastreId:-sense-rastre}] %logger{36} - %msg%n"El resultat, al log del servidor i a la resposta que rep l'operari:
09:22:41.117 WARN [a3f5c9e1] c.c.c.GestorGlobalExcepcions - Conflicte a /api/v1/lloguers/7/finalitzar: L'estació Universitat no té ancoratges lliures
{ "type": "https://api.ciclourbana.example/errors/estacio-plena",
"title": "Conflicte amb l'estat actual", "status": 409,
"detail": "L'estació Universitat no té ancoratges lliures (capacitat 36)",
"codi": "ESTACIO_PLENA", "rastre": "a3f5c9e1" }Amb a3f5c9e1 es localitza al log la petició exacta i totes les seves línies. Aquest identificador és la llavor de la traçabilitat distribuïda de 09-06, on el rastre continuarà viu a través de diversos serveis.
Errors Comuns i Consells
Deixar include-stacktrace: always en producció. És la fuita d'informació més comuna en aplicacions Spring Boot. Ha de ser never.
Capturar Exception al controlador amb un try/catch. Anul·la el gestor global i escampa la lògica d'errors. Deixa que l'excepció pugi.
Retornar e.getMessage() d'una excepció aliena. És la via per la qual surten esquemes de base de dades i rutes del sistema.
Registrar-ho tot amb log.error. Un 404 no és un error del servidor. Quan tot és ERROR, les alertes deixen de significar res.
Oblidar MDC.remove(). El rastre es filtra a la petició següent del mateix fil i corromp el log just quan més el necessites.
Fer servir @ResponseStatus i el gestor global alhora. Tenir el mapatge en dos llocs acaba en incoherències; el curs tria el gestor. I fer servir Map<String, String> per als errors de validació perd en silenci els errors addicionals d'un mateix camp: fes servir Map<String, List<String>>.
Consell: escriu primer la taula d'excepció a codi HTTP, que és l'especificació del gestor, i prova cada branca d'error afegint al fitxer .http una petició per tipus; al mòdul 6 aquestes peticions es converteixen en proves d'integració que impedeixen que un refactor trenqui el contracte d'errors.
Exercicis
Exercici 1: Excepció de duplicat amb context
EstacioService.crear continua llançant IllegalStateException quan el nom està repetit. Crea EstacioDuplicadaException, fes-la respondre 409 i afegeix a la resposta l'identificador de l'estació que ja existeix, perquè el tauler de Ribalta pugui enllaçar-la directament.
Exercici 2: Errors de validació internacionalitzats i amb el valor rebutjat
Amplia el gestor de MethodArgumentNotValidException perquè cada error inclogui el nom del camp, el missatge traduït segons Accept-Language i el valor rebutjat, amb una llista de camps sensibles el valor dels quals no es retorna mai.
Exercici 3: Gestor de 405 amb la capçalera Allow
El gestor de HttpRequestMethodNotSupportedException de l'apartat 9 retorna el 405 correcte però no inclou la capçalera Allow, que l'RFC 9110 exigeix. Corregeix-ho i explica per què aquest cas necessita ResponseEntity mentre que els altres no.
Solucions
Solució 1.
public class EstacioDuplicadaException extends ConflicteRecursException {
private final Long idExistent;
public EstacioDuplicadaException(String nom, Long idExistent) {
super("ESTACIO_DUPLICADA", "Ja existeix una estació anomenada '%s'".formatted(nom));
this.idExistent = idExistent;
}
public Long getIdExistent() { return idExistent; }
}
// A EstacioService
public Estacio crear(Estacio nova) {
estacioRepositori.cercarPerNom(nova.nom()).ifPresent(existent -> {
throw new EstacioDuplicadaException(nova.nom(), existent.id());
});
return estacioRepositori.desar(nova);
}Observa el canvi al repositori: existeixPerNom retornava un boolean i ara necessitem cercarPerNom, que retorna Optional<Estacio>. És un patró recurrent: si en llançar l'error necessites dades del recurs en conflicte, el repositori t'ha de retornar l'objecte, no un booleà. El gestor específic:
@ExceptionHandler(EstacioDuplicadaException.class)
public ProblemDetail gestionarEstacioDuplicada(EstacioDuplicadaException e) {
ProblemDetail problema = construir(HttpStatus.CONFLICT, "Estació duplicada", e);
problema.setProperty("idExistent", e.getIdExistent());
problema.setProperty("enllacExistent", "/api/v1/estacions/" + e.getIdExistent());
return problema;
}{ "type": "https://api.ciclourbana.example/errors/estacio-duplicada",
"title": "Estació duplicada", "status": 409,
"detail": "Ja existeix una estació anomenada 'Plaça Major'",
"codi": "ESTACIO_DUPLICADA", "idExistent": 1,
"enllacExistent": "/api/v1/estacions/1" }El camp enllacExistent és un toc d'HATEOAS on de veritat aporta: el tauler pot oferir "veure l'estació existent" sense construir l'URL. I encara que no existís un gestor per a EstacioDuplicadaException, l'excepció continuaria responent 409 gràcies al de la seva superclasse ConflicteRecursException; el gestor específic només hi afegeix els dos camps extra. Aquest és l'avantatge d'una jerarquia ben dissenyada.
Solució 2.
/** Camps el valor dels quals NO es retorna MAI, encara que hagi fallat la validació. */
private static final Set<String> CAMPS_SENSIBLES =
Set.of("contrasenya", "password", "token", "targeta", "cvv", "dni", "iban");
public record ErrorCamp(String camp, String missatge, Object valorRebutjat) {}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail gestionarValidacio(MethodArgumentNotValidException e, Locale idioma) {
List<ErrorCamp> errors = e.getBindingResult().getFieldErrors().stream()
// getMessage(error, idioma) resol la clau contra el
// MessageSource de 03-04 amb l'idioma d'Accept-Language
.map(error -> new ErrorCamp(error.getField(),
messageSource.getMessage(error, idioma), valorSegur(error)))
.toList();
ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"La petició conté %d error(s) de validació".formatted(errors.size()));
problema.setTitle("Error de validació");
problema.setType(BASE_TIPUS.resolve("validacio"));
problema.setProperty("codi", "VALIDACIO");
problema.setProperty("errors", errors);
return problema;
}
/** Retorna el valor rebutjat llevat que el camp sigui sensible. */
private Object valorSegur(FieldError error) {
String camp = error.getField().toLowerCase();
if (CAMPS_SENSIBLES.stream().anyMatch(camp::contains)) {
return "***";
}
Object valor = error.getRejectedValue();
// Truncar: un camp de text pot portar megabytes i ompliria la resposta
return valor instanceof String text && text.length() > 100
? text.substring(0, 100) + "..." : valor;
}Amb Accept-Language: en:
{ "title": "Error de validació", "status": 400,
"detail": "La petició conté 2 error(s) de validació",
"codi": "VALIDACIO",
"errors": [
{ "camp": "nom", "missatge": "The station name is required", "valorRebutjat": "" },
{ "camp": "capacitat", "missatge": "Capacity must be greater than zero",
"valorRebutjat": -5 } ] }Tres decisions que val la pena assenyalar. Primera: Locale com a paràmetre del gestor, que Spring injecta a partir del LocaleResolver de 03-04, de manera que messageSource.getMessage(error, idioma) resol {estacio.nom.obligatori} en l'idioma correcte. Segona: la llista de camps sensibles és una llista negra, amb el problema que ja coneixem —un camp nou anomenat numeroSeguretatSocial no hi seria—, així que en un sistema amb dades veritablement sensibles el correcte és a l'inrevés: no retornar cap valor llevat dels explícitament marcats com a segurs. Tercera: el truncament a 100 caràcters evita que un client maliciós provoqui respostes enormes.
Solució 3.
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ProblemDetail> gestionarMetodeNoSuportat(
HttpRequestMethodNotSupportedException e) {
ProblemDetail problema = ProblemDetail.forStatusAndDetail(
HttpStatus.METHOD_NOT_ALLOWED,
"El mètode %s no està permès en aquesta ruta".formatted(e.getMethod()));
problema.setTitle("Mètode no permès");
problema.setType(BASE_TIPUS.resolve("metode-no-permes"));
problema.setProperty("codi", "METODE_NO_PERMES");
problema.setProperty("metodesPermesos", e.getSupportedMethods());
HttpHeaders capcaleres = new HttpHeaders();
Set<HttpMethod> permesos = e.getSupportedHttpMethods();
if (permesos != null && !permesos.isEmpty()) {
capcaleres.setAllow(permesos); // capçalera Allow: GET, POST, ...
}
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.headers(capcaleres).body(problema);
}Davant de DELETE /api/v1/estacions, la resposta és 405 amb Allow: GET, POST i un cos application/problem+json que repeteix els mètodes a metodesPermesos.
Per què aquest cas necessita ResponseEntity. ProblemDetail descriu únicament el cos de la resposta: té status perquè Spring fixi el codi, però no té manera d'expressar capçaleres. L'RFC 9110 diu que un 405 ha d'incloure Allow, així que cal embolcallar-lo. És exactament el criteri que vam fixar a la taula de 03-02: ResponseEntity quan calen capçaleres, l'objecte directe quan no.
Val la pena notar la duplicitat deliberada: els mètodes permesos apareixen a la capçalera Allow i al camp metodesPermesos. La capçalera és el que exigeix l'estàndard i el que llegeixen els intermediaris; el camp del cos és el que la majoria dels clients JavaScript llegiran a la pràctica, perquè accedir a capçaleres des del navegador exigeix la configuració exposedHeaders de CORS que vam veure a 03-02. Duplicar aquí costa una línia i estalvia un problema.
Conclusió
El deute de quatre lliçons queda saldat. Saps què fa Spring Boot per defecte —el reenviament a /error i el BasicErrorController— i per què les propietats server.error.include-* no basten per a una API pública, començant pel fet que tot surt com a 500. Has construït la jerarquia d'excepcions de CicloUrbana amb CicloUrbanaException com a base, el seu camp codi que fa l'API programable sense dependre del text, i les excepcions concretes del domini de Ribalta, cadascuna amb el seu codi HTTP en una taula que és l'especificació del gestor. Coneixes @ResponseStatus i els seus tres límits, @ExceptionHandler local i la seva precedència sobre el global, i sobretot el @RestControllerAdvice que centralitza la gestió en una sola classe, amb la resolució per especificitat de tipus que permet declarar un gestor d'Exception sense por que es mengi els altres.
Els errors de CicloUrbana parlen ja l'estàndard RFC 7807 Problem Details, amb type, title, status, detail, instance i les extensions pròpies, servits amb application/problem+json i complementats per spring.mvc.problemdetails.enabled. Els errors de validació de 03-04 retornen per fi la llista exacta de camps erronis, agrupats en Map<String, List<String>> per no perdre'n cap, i la ConstraintViolationException ja no surt com a 500. Gestiones les excepcions del framework —JSON il·legible, tipus incorrecte, mètode no permès, ruta inexistent— i saps que cap no ha de filtrar e.getMessage(). Tens la taula de què no ha de sortir mai en un error, la política de nivells de log per gravetat, i un identificador de rastreig propagat amb l'MDC que apareix alhora a la resposta i a totes les línies del log d'aquesta petició, amb el MDC.remove() al finally que evita corrompre el diagnòstic.
CicloUrbana té ara una API completa: tretze endpoints, validació a la vora, DTO que separen el domini del contracte i errors uniformes i ben formats. I tanmateix, si demà l'equip de l'app mòbil de Ribalta o el del portal de dades obertes de l'ajuntament es volguessin integrar, ens haurien de preguntar endpoint per endpoint quins camps accepta cadascun, què retorna i quins errors pot donar. Tot aquest coneixement és al codi i al nostre cap, i enlloc més.
La lliçó 03-07, Documentar l'API amb OpenAPI, ho posa per escrit de manera automàtica i llegible per màquines. Veurem què és OpenAPI 3.1 i per què un contracte formal canvia la forma de treballar entre equips; compararem code-first i design-first; integrarem springdoc-openapi amb els seus endpoints /v3/api-docs i /swagger-ui.html; documentarem CicloUrbana amb @Tag, @Operation, @Parameter, @ApiResponse i @Schema; veurem com les restriccions de Bean Validation de 03-04 i els Problem Details d'aquesta lliçó apareixen sols a l'esquema; agruparem endpoints amb GroupedOpenApi; exportarem l'openapi.json al build per generar clients; i tancarem el mòdul amb el balanç de l'API abans de saltar a la persistència real.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
