La lliçó anterior va deixar una promesa sense complir: vam dir que un servei en crida un altre, que cal propagar el JWT i l'identificador de rastre, que fa falta un tallacircuits i que la caiguda d'un servei no s'ha d'encadenar. Res d'això no ho hem escrit encara. I fa falta encara que CicloUrbana no es divideixi mai: tan bon punt l'aplicació crida la passarel·la de pagaments de Ribalta —un sistema extern, amb la seva latència, les seves caigudes i els seus errors 500— apareixen exactament els mateixos problemes.
Aquesta lliçó els resol amb codi. Primer el client: quines opcions ofereix Spring, com es configura RestClient per parlar amb la passarel·la, per què els temps d'espera són l'ajust més car d'oblidar, com es propaguen el rastre i el token, i com es tradueix un error remot a una excepció del domini. Després la resiliència: els cinc patrons de Resilience4j un a un, en quin ordre s'apliquen, i la decisió —de negoci, no tècnica— de què fa CicloUrbana quan la passarel·la no respon. I al final, com es prova tot això simulant un servei lent, caigut i trencat.
Contingut
- Els clients HTTP d'Spring
- El client de la passarel·la de pagaments
- Temps d'espera: l'ajust que no es pot oblidar
- Interceptors: propagar el rastre i el token
- Traduir els errors remots al domini
- La interfície declarativa amb
@HttpExchange - Resilience4j: instal·lació i configuració
- Reintents
- Tallacircuits
- Limitador de taxa, mampara i temps límit
- L'ordre dels decoradors
- Degradació elegant
- Observabilitat de la resiliència
- Provar les fallades amb WireMock
- Comunicació asíncrona: el mínim imprescindible
- Errors Comuns i Consells
- Exercicis
- Els clients HTTP d'Spring
| Client | Estat | Model | Quan fer-lo servir |
|---|---|---|---|
RestTemplate |
En manteniment des d'Spring 5 | Bloquejant | Només codi heretat; no començar res nou amb ell |
RestClient |
Spring Framework 6.1+ | Bloquejant, API fluida | L'opció recomanada en aplicacions síncrones com CicloUrbana |
WebClient |
Estable | Reactiu (Mono/Flux) |
WebFlux, o crides concurrents amb composició |
@HttpExchange + HttpServiceProxyFactory |
Spring 6+ | Interfície declarativa | Clients amb diversos mètodes: la preferida en aquest curs |
| OpenFeign | Spring Cloud | Interfície declarativa | Projectes que ja el fan servir; l'estàndard d'Spring el substitueix |
Dos aclariments útils. RestTemplate no està obsolet ni desapareixerà, però no rep funcionalitat nova; migrar a RestClient és gairebé mecànic perquè comparteix la infraestructura (ClientHttpRequestFactory, interceptors, convertidors). I fer servir WebClient en una aplicació servlet només per cridar un servei i fer .block() és un antipatró comú: arrossega tota la pila reactiva per obtenir el comportament de RestClient amb més complexitat. L'elecció per a CicloUrbana: RestClient com a base i @HttpExchange per exposar-lo com a interfície de domini.
- El client de la passarel·la de pagaments
Partim de la PassarelaProperties de 02-05, ampliada amb els temps d'espera:
@ConfigurationProperties(prefix = "ciclourbana.passarela")
@Validated
public record PassarelaProperties(
@NotBlank String url,
@NotBlank String apiKey,
@NotNull @DurationMin(millis = 200) @DurationMax(seconds = 10) Duration esperaConnexio,
@NotNull @DurationMin(millis = 200) @DurationMax(seconds = 30) Duration esperaLectura) {
}@Bean
RestClient clientPassarela(PassarelaProperties propietats, InterceptorRastreig interceptorRastreig) {
ClientHttpRequestFactorySettings ajustos = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(propietats.esperaConnexio()) // 2s
.withReadTimeout(propietats.esperaLectura()); // 5s
return RestClient.builder()
.baseUrl(propietats.url()) // https://pagaments.ribalta.example/api/v1
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + propietats.apiKey())
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.USER_AGENT, "CicloUrbana/2.4.0")
.requestFactory(ClientHttpRequestFactories.get(ajustos))
.requestInterceptor(interceptorRastreig)
.defaultStatusHandler(HttpStatusCode::isError, this::traduirError)
.build();
}Quatre decisions. És un bean, no un RestClient creat a cada crida: crear-lo per invocació descarta el pool de connexions i afegeix una negociació TLS completa a cada petició. La clau de l'API va com a capçalera per defecte, així que cap mètode no la pot oblidar —i mai a la URL, on acabaria als logs d'accés i als proxies intermedis—. L'User-Agent identifica CicloUrbana als logs de la passarel·la, cosa que estalvia discussions quan cal investigar un incident amb el proveïdor. I hi ha un client per destinació: si demà apareix un servei de mapes, tindrà el seu propi bean amb els seus propis temps i el seu propi tallacircuits.
- Temps d'espera: l'ajust que no es pot oblidar
No configurar els temps d'espera és l'error més car de tota la lliçó, i és fàcil de cometre perquè no produeix cap símptoma fins al dia que el sistema remot es degrada.
Sense ells, una petició a una passarel·la que accepta la connexió i no respon mai espera indefinidament, i el fil que atén el ciutadà queda bloquejat. Amb dos-cents ciutadans intentant pagar, els dos-cents fils de Tomcat queden retinguts, i a partir d'aquí CicloUrbana sencera deixa de respondre: ni consultar estacions, ni iniciar lloguers, ni tan sols /actuator/health si comparteix port. Un problema d'un tercer s'ha convertit en una caiguda total del servei municipal.
| Temps | Què mesura | Valor raonable | Si no es configura |
|---|---|---|---|
| Connexió | Establir el sòcol TCP | 1-3 s | Espera del sistema operatiu: minuts |
| Lectura | Entre bytes de la resposta | 3-10 s | Infinit |
| Espera de connexió del pool | Obtenir una connexió lliure | 1-2 s | Pot bloquejar indefinidament |
| Global de la crida | Tota l'operació amb reintents | Suma acotada | No existeix: cal imposar-lo |
La regla per fixar-los: parteix del pressupost de latència del teu propi endpoint. Si POST /api/v1/lloguers/{id}/finalitzar ha de respondre en menys d'un segon, no es pot permetre una espera de lectura de trenta; és més útil fallar ràpid i degradar (apartat 12) que esperar una resposta que ja no serveix a ningú. I un advertiment sobre els reintents: l'espera efectiva es multiplica —amb una lectura de 5 s i tres intents, el pitjor cas són 15 s més les pauses—, que és per això que l'apartat 11 insisteix en l'ordre dels decoradors i en un límit global.
- Interceptors: propagar el rastre i el token
Un interceptor s'executa abans de cada petició sortint i és el lloc natural per a les capçaleres transversals:
package com.ciclourbana.comu;
@Component
public class InterceptorRastreig implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest peticio, byte[] cos,
ClientHttpRequestExecution execucio) throws IOException {
String rastre = MDC.get(FiltreRastreig.CLAU_MDC);
if (rastre != null) {
peticio.getHeaders().add("X-Rastre-Id", rastre);
}
long inici = System.nanoTime();
ClientHttpResponse resposta = execucio.execute(peticio, cos);
log.debug("{} {} -> {} en {} ms", peticio.getMethod(), peticio.getURI(),
resposta.getStatusCode(),
Duration.ofNanos(System.nanoTime() - inici).toMillis());
return resposta;
}
}Amb aquesta capçalera, l'identificador de rastre del FiltreRastreig de 03-06 viatja al sistema remot i apareix als seus logs: quan el proveïdor de la passarel·la pregunti per una transacció concreta, la referència és la mateixa a banda i banda. A 09-06, Micrometer Tracing farà això de manera estàndard amb les capçaleres traceparent del W3C. Per propagar el JWT cap a un altre servei de CicloUrbana —el token relay de 07-05— l'interceptor llegeix el context de seguretat:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getCredentials() instanceof String token) {
peticio.getHeaders().setBearerAuth(token);
}
return execucio.execute(peticio, cos);Mai cap a un tercer: aquest interceptor només s'ha de registrar en clients que apunten a serveis propis; enviar el token d'un ciutadà de Ribalta a la passarel·la de pagaments externa seria una filtració de credencials, i per això la passarel·la fa servir la seva pròpia apiKey. I el SecurityContextHolder és un ThreadLocal: si la crida surt d'un fil asíncron (07-03), aquí no hi haurà res tret que l'executor estigui embolcallat en DelegatingSecurityContextAsyncTaskExecutor.
- Traduir els errors remots al domini
Sense tractament, un 402 Payment Required de la passarel·la es converteix en una HttpClientErrorException que puja fins al GestorGlobalExcepcions de 03-06, que la desconeix i retorna un 500 genèric. Al ciutadà li arriba «error intern» quan el que passa és que la seva targeta no té saldo.
private void traduirError(HttpRequest peticio, ClientHttpResponse resposta) throws IOException {
HttpStatusCode estat = resposta.getStatusCode();
String cos = new String(resposta.getBody().readAllBytes(), StandardCharsets.UTF_8);
log.warn("La passarel·la ha respost {} a {}", estat, peticio.getURI());
if (estat.value() == 402) throw new PagamentRebutjatException(extreureMotiu(cos));
if (estat.is4xxClientError()) throw new PassarelaRebuigException("Petició rebutjada: " + estat);
throw new PassarelaNoDisponibleException("La passarel·la ha retornat " + estat);
}I al gestor global, tres regles noves coherents amb el ProblemDetail de 03-06:
| Excepció pròpia | Estat cap al ciutadà | Motiu |
|---|---|---|
PagamentRebutjatException |
402 Payment Required |
És un problema del ciutadà i el pot resoldre |
PassarelaRebuigException |
500 |
Un 4xx de la passarel·la és una fallada nostra: petició mal formada |
PassarelaNoDisponibleException |
503 Service Unavailable |
És temporal; s'hi afegeix Retry-After |
Dos principis darrere d'aquesta taula. L'excepció de transport no ha d'escapar: que el client faci servir RestClient, Feign o un sòcol pelat és un detall d'implementació, i HttpClientErrorException a la signatura d'un servei de domini acobla l'aplicació a la biblioteca. I el codi d'estat cap al ciutadà no és el del remot: que la passarel·la retorni 400 perquè vam enviar un camp malament no significa que la petició del ciutadà fos incorrecta.
Compte, a més, amb allò que es registra: el cos de la resposta d'una passarel·la de pagaments pot contenir dades de targeta, així que mai no s'ha d'abocar sencer al log ni, molt menys, al missatge de l'excepció que arriba al client.
- La interfície declarativa amb
@HttpExchange
@HttpExchangeAmb més de dues o tres operacions, el RestClient nu dispersa URI i tipus pel codi. La interfície declarativa els reuneix en un contracte llegible:
package com.ciclourbana.pagaments;
@HttpExchange(url = "/pagaments", accept = "application/json", contentType = "application/json")
public interface ClientPassarelaPagaments {
@PostExchange
RespostaCobrament cobrar(@RequestBody SolicitudCobrament solicitud);
@GetExchange("/{referencia}")
RespostaCobrament consultar(@PathVariable String referencia);
@PostExchange("/{referencia}/devolucions")
RespostaDevolucio retornar(@PathVariable String ref, @RequestBody SolicitudDevolucio s);
}@Bean
ClientPassarelaPagaments clientPassarelaPagaments(RestClient clientPassarela) {
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(clientPassarela))
.build()
.createClient(ClientPassarelaPagaments.class);
}Spring genera la implementació, que fa servir per sota el RestClient de l'apartat 2 amb els seus temps d'espera, els seus interceptors i el seu traductor d'errors. Els avantatges davant del client nu: la interfície és el contracte i es llegeix d'un cop d'ull, el servei de domini depèn d'una abstracció pròpia i no d'una biblioteca HTTP, i a les proves se substitueix per un mock de Mockito (06-03) sense aixecar res.
@HttpExchange és l'equivalent estàndard d'OpenFeign sense dependre d'Spring Cloud, amb una correspondència gairebé un a un (@FeignClient → @HttpExchange, @GetMapping → @GetExchange).
- Resilience4j: instal·lació i configuració
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>spring-boot-starter-aop no és opcional: les anotacions de Resilience4j funcionen amb proxies, amb els mateixos paranys d'autoinvocació de @Transactional (04-07) i @Async (07-03) —un mètode anotat que es crida amb this. no està protegit per res—. Tota la configuració és YAML, amb configs.default heretable més ajustos per instància:
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50 # % de fallades que obre el circuit
slowCallRateThreshold: 80 # % de crides lentes que també l'obre
slowCallDurationThreshold: 3s
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
registerHealthIndicator: true
recordExceptions: [com.ciclourbana.pagaments.PassarelaNoDisponibleException, java.io.IOException]
ignoreExceptions: [com.ciclourbana.pagaments.PagamentRebutjatException]
instances:
passarelaPagaments: { baseConfig: default }
retry:
instances:
passarelaPagaments:
maxAttempts: 3
waitDuration: 500ms
exponentialBackoffMultiplier: 2
enableRandomizedWait: true
randomizedWaitFactor: 0.5
retryExceptions: [com.ciclourbana.pagaments.PassarelaNoDisponibleException, java.io.IOException]
ignoreExceptions: [com.ciclourbana.pagaments.PagamentRebutjatException]
ratelimiter:
instances:
passarelaPagaments: { limitForPeriod: 50, limitRefreshPeriod: 1s, timeoutDuration: 200ms }
bulkhead:
instances:
passarelaPagaments: { maxConcurrentCalls: 10, maxWaitDuration: 100ms }
timelimiter:
instances:
passarelaPagaments: { timeoutDuration: 8s, cancelRunningFuture: true }ignoreExceptions amb PagamentRebutjatException és la línia més important de tot el bloc. Una targeta sense saldo és una resposta correcta de la passarel·la: no s'ha de reintentar ni comptar com a fallada per obrir el tallacircuits. Si es comptés, mil ciutadans amb la targeta caducada bastarien per deixar sense cobraments tota la xarxa de Ribalta.
- Reintents
Un reintent resol fallades transitòries: una pèrdua de paquets, un 503 durant un desplegament del proveïdor, un pic momentani.
@Retry(name = "passarelaPagaments")
public RespostaCobrament cobrar(Long lloguerId, BigDecimal importTotal) {
return client.cobrar(new SolicitudCobrament(lloguerId, importTotal, clauIdempotencia(lloguerId)));
}Dos conceptes que cal entendre.
Retrocés exponencial amb jitter. Reintentar cada 500 ms de manera fixa té un efecte pervers: si mil peticions fallen alhora perquè la passarel·la s'ha reiniciat, les mil reintenten alhora i la tomben de nou —la tempesta de reintents—. El retrocés exponencial separa els intents (500 ms, 1 s, 2 s) i el jitter (enableRandomizedWait) hi afegeix variació aleatòria: amb randomizedWaitFactor: 0.5, la segona espera cau entre 250 i 750 ms.
Idempotència: què és segur reintentar. Aquesta és la regla que decideix si el reintent ajuda o duplica cobraments, i enllaça amb la semàntica dels mètodes HTTP de 03-03:
| Operació | Reintentar? | Motiu |
|---|---|---|
GET, PUT, DELETE |
Sí | Consultes sense efectes o operacions idempotents per definició |
POST /pagaments amb clau d'idempotència |
Sí | El servidor descarta el duplicat |
POST /pagaments sense clau |
No | La fallada va poder passar després de cobrar: es cobraria dues vegades |
El cas perillós és un temps d'espera exhaurit: no se sap si l'operació es va executar o no —la resposta es va perdre, però el cobrament es va poder aplicar—. La solució és la clau d'idempotència: CicloUrbana envia a cada cobrament una clau derivada del lloguer (cobrament-lloguer-42) i la passarel·la, si ja l'ha vista, retorna el resultat original sense tornar a cobrar. Sense aquest mecanisme del costat servidor, reintentar un POST de cobrament és inacceptable.
- Tallacircuits
El reintent serveix per a fallades passatgeres. Quan la fallada és sostinguda, reintentar empitjora les coses: consumeix fils, allarga les respostes i masega un sistema que ja és caigut. El tallacircuits talla aquesta realimentació.
stateDiagram-v2
[*] --> TANCAT
TANCAT --> OBERT: taxa de fallada > 50%<br/>(mínim 10 crides)
OBERT --> SEMIOBERT: passen 30 s
SEMIOBERT --> TANCAT: les 3 crides de prova van bé
SEMIOBERT --> OBERT: alguna torna a fallar
note right of TANCAT: Tot passa. Es mesura.
note right of OBERT: No surt res. Falla a l'instant<br/>i s'executa la reserva.
note right of SEMIOBERT: Passen unes poques<br/>crides de prova.
L'estat OBERT és el que aporta el valor: es llança CallNotPermittedException immediatament, de manera que es deixa d'esperar cinc segons per cada petició i es falla en microsegons, sense tocar el sistema caigut.
@CircuitBreaker(name = "passarelaPagaments", fallbackMethod = "cobramentDiferit")
@Retry(name = "passarelaPagaments")
public ResultatCobrament cobrar(Long lloguerId, BigDecimal importTotal) {
RespostaCobrament resposta = client.cobrar(
new SolicitudCobrament(lloguerId, importTotal, "cobrament-lloguer-" + lloguerId));
return ResultatCobrament.cobrat(resposta.referencia());
}
/** S'executa quan el circuit és obert o la crida falla definitivament. */
private ResultatCobrament cobramentDiferit(Long lloguerId, BigDecimal importTotal, Throwable causa) {
log.warn("Passarel·la no disponible ({}), es difereix el cobrament del lloguer {}",
causa.toString(), lloguerId);
cuaCobramentsPendents.encuar(new CobramentPendent(lloguerId, importTotal, Instant.now(rellotge)));
return ResultatCobrament.diferit();
}Quatre regles sobre el fallbackMethod, totes font d'errors freqüents:
- Mateixa signatura més un últim paràmetre
Throwable. Si no coincideix, Resilience4j no el troba i l'excepció original s'escapa, en silenci, en temps d'execució. - Ha de ser a la mateixa classe i pot ser
private. - Poden ser-ne diversos, especialitzats per tipus d'excepció; guanya el més específic.
- No ha de fallar ni ser lent. Una reserva que crida un altre servei remot reintrodueix el problema que venia a resoldre.
Els dos llindars. failureRateThreshold: 50 obre el circuit amb la meitat de fallades, però slowCallRateThreshold: 80 amb slowCallDurationThreshold: 3s és igual d'important —un servei lent fa més mal que un de caigut, perquè reté fils sense retornar res—, i minimumNumberOfCalls: 10 evita que dues fallades en un moment de baix trànsit obrin el circuit.
- Limitador de taxa, mampara i temps límit
@RateLimiter protegeix de superar una quota: la passarel·la de Ribalta admet 50 peticions per segon i sobrepassar-les produeix 429 i, en alguns contractes, un recàrrec. Amb timeoutDuration: 200ms, una crida que no obté permís en aquest termini llança RequestNotPermitted. És diferent del Bucket4j de 05-05: aquell limitava allò que entra a CicloUrbana; aquest limita allò que surt cap al tercer.
@Bulkhead —mampara, pels compartiments estancs d'un vaixell— limita les crides concurrents a un recurs: amb @Bulkhead(name = "passarelaPagaments", type = Bulkhead.Type.SEMAPHORE) i maxConcurrentCalls: 10, com a molt deu fils esperen la passarel·la alhora; la resta falla ràpid en lloc d'acumular-se. És el que impedeix que un tercer lent exhaureixi el pool de Tomcat: sense mampara, dues-centes peticions concurrents a un servei de cinc segons retenen els dos-cents fils i tomben CicloUrbana sencera. És el mateix principi que vam aplicar a 07-03 en donar un executor propi al correu.
@TimeLimiter imposa un topall global, i només funciona sobre mètodes que retornen CompletableFuture, perquè necessita poder cancel·lar:
@TimeLimiter(name = "passarelaPagaments")
@CircuitBreaker(name = "passarelaPagaments", fallbackMethod = "cobramentDiferitAsync")
public CompletableFuture<ResultatCobrament> cobrarAsync(Long lloguerId, BigDecimal importTotal) {
return CompletableFuture.supplyAsync(() -> cobrar(lloguerId, importTotal), executorPagaments);
}En una aplicació bloquejant com CicloUrbana els temps d'espera del RestClient (apartat 3) cobreixen el cas habitual; @TimeLimiter aporta el topall de l'operació completa, inclosos els reintents i les seves pauses, que és justament el que les esperes individuals no acoten.
- L'ordre dels decoradors
Quan diverses anotacions s'apliquen al mateix mètode, l'ordre importa molt. El que aplica Resilience4j per defecte, de fora cap a dins:
Es llegeix així: primer es demana lloc a la mampara; a dins, el temps límit acota tota l'operació; després el limitador concedeix permís; llavors el tallacircuits decideix si s'intenta tan sols; i el reintent queda a l'interior, embolcallant únicament la crida real. Que Retry sigui dins de CircuitBreaker és la decisió clau:
| Ordre | Conseqüència |
|---|---|
CircuitBreaker fora, Retry dins (per defecte) |
Amb el circuit obert no es reintenta res: es falla a l'instant. Cada grup de reintents compta com un resultat per a l'estadística del circuit |
Retry fora, CircuitBreaker dins |
Es reintentaria contra un circuit obert, gastant temps per res; i tres fallades d'una mateixa operació comptarien com a tres, obrint el circuit abans d'hora |
L'ordre per defecte és el correcte en gairebé tots els casos, i es pot canviar amb les propietats ...retryAspectOrder i ...circuitBreakerAspectOrder si calgués.
- Degradació elegant
Arriba la pregunta central de la lliçó: què fa CicloUrbana quan la passarel·la de pagaments no respon?
| Opció | Conseqüència per al ciutadà | Conseqüència per a l'ajuntament |
|---|---|---|
Retornar 503 i no finalitzar el lloguer |
No pot tornar la bicicleta; l'ancoratge queda ocupat | La xarxa es paralitza per una fallada d'un tercer |
| Finalitzar i encuar el cobrament | Torna la bicicleta amb normalitat | Risc d'impagament si el cobrament falla després |
| Finalitzar sense cobrar mai | Servei gratuït | Pèrdua directa |
La decisió de CicloUrbana és la segona: finalitzar el lloguer i diferir el cobrament, que és el que fa el cobramentDiferit de l'apartat 9. El raonament és de negoci, no tècnic: el ciutadà ja va tornar la bicicleta —això és un fet físic que va passar— i bloquejar l'operació no ho desfà, només deixa un ancoratge inutilitzat i un ciutadà atrapat. L'import és petit, la identitat del ciutadà és coneguda i el cobrament es pot reintentar més tard amb una tasca programada (07-03) sobre els CobramentPendent encuats.
I aquesta decisió no la pren el desenvolupador: canviaria del tot si l'import fos de mil euros, si el ciutadà fos anònim o si la normativa exigís cobrament previ. La regla general és que la reserva és una decisió de producte i el codi només la implementa, i les preguntes que cal portar a aquesta conversa són sempre les mateixes: podem servir dades una mica antigues?, acceptar l'operació i completar-la després?, oferir una funcionalitat reduïda?, o aquesta operació és realment indispensable?
Un catàleg de degradacions típiques: servir l'última resposta a la memòria cau (09-02) quan la dada tolera antiguitat; retornar un valor per defecte segur; encuar i confirmar, com aquí; o deshabilitar la funcionalitat mantenint la resta.
- Observabilitat de la resiliència
Un tallacircuits que s'obre sense que ningú se n'assabenti és tan dolent com no tenir-lo: el sistema degrada en silenci. Amb registerHealthIndicator: true, cada circuit apareix com a component de salut a /actuator/health (07-01), i Resilience4j afegeix els seus propis endpoints:
curl -s -u admin:*** http://localhost:8081/actuator/circuitbreakers
# { "circuitBreakers": { "passarelaPagaments": { "state": "OPEN", "failureRate": "72.0%",
# "slowCallRate": "15.0%", "bufferedCalls": 20, "failedCalls": 14 } } }
curl -s -u admin:*** http://localhost:8081/actuator/circuitbreakerevents
curl -s -u admin:*** http://localhost:8081/actuator/retriesCompte amb l'indicador de salut. Si el circuit de la passarel·la entra al grup readiness, obrir-se trauria la instància del balancejador —justament l'escenari que vam advertir a 07-01—. Un tallacircuits obert significa «el tercer és caigut i estic degradant correctament», no «estic malalt»: s'ha de veure a /actuator/health i disparar una alerta, però no ser a readiness.
Les mètriques que Resilience4j publica a Micrometer —resilience4j_circuitbreaker_state, ..._calls, resilience4j_retry_calls— s'exploten a 09-03 i es representen a Grafana a 09-04, amb dues alertes mínimes: circuit obert més d'un minut i taxa de reintents per damunt del normal, que és l'avís primerenc d'una degradació abans que el circuit arribi a obrir-se.
- Provar les fallades amb WireMock
Tot l'anterior només serveix si està provat, i no es pot provar demanant al proveïdor que caigui. WireMock aixeca un servidor HTTP que fingeix ser la passarel·la i es comporta com se li indiqui (dependència org.wiremock:wiremock-standalone, àmbit test).
@SpringBootTest
@ActiveProfiles("test")
class ResilienciaPassarelaIT {
static WireMockServer passarela = new WireMockServer(options().dynamicPort());
@BeforeAll static void arrencar() { passarela.start(); }
@AfterAll static void parar() { passarela.stop(); }
@DynamicPropertySource
static void propietats(DynamicPropertyRegistry registre) {
registre.add("ciclourbana.passarela.url", () -> passarela.baseUrl() + "/api/v1");
}
@Autowired ServeiPagaments serveiPagaments;
@Autowired CircuitBreakerRegistry registre;
@BeforeEach void netejar() {
passarela.resetAll();
registre.circuitBreaker("passarelaPagaments").reset();
}
@Test
void obreElCircuitDespresDeFallesSostingudesIResponLaReserva() {
passarela.stubFor(post(urlPathEqualTo("/api/v1/pagaments"))
.willReturn(aResponse().withStatus(500)));
for (int i = 0; i < 12; i++) {
serveiPagaments.cobrar((long) i, new BigDecimal("4.80"));
}
assertThat(registre.circuitBreaker("passarelaPagaments").getState())
.isEqualTo(CircuitBreaker.State.OPEN);
passarela.resetRequests();
ResultatCobrament resultat = serveiPagaments.cobrar(99L, new BigDecimal("4.80"));
assertThat(resultat.estat()).isEqualTo(EstatCobrament.DIFERIT);
passarela.verify(0, postRequestedFor(urlPathEqualTo("/api/v1/pagaments")));
}
@Test
void laPassarelaLentaNoBloquejaElCiutada() {
passarela.stubFor(post(urlPathEqualTo("/api/v1/pagaments"))
.willReturn(okJson("{}").withFixedDelay(30_000)));
long inici = System.currentTimeMillis();
ResultatCobrament resultat = serveiPagaments.cobrar(7L, new BigDecimal("4.80"));
assertThat(resultat.estat()).isEqualTo(EstatCobrament.DIFERIT);
assertThat(System.currentTimeMillis() - inici).isLessThan(20_000);
}
}Els asserts que donen valor a aquestes proves són els que no miren el resultat feliç: verify(0, ...) amb el circuit obert demostra que no va sortir ni una petició, que és l'essència del patró, i la comprovació de temps demostra que un tercer que triga trenta segons no arrossega CicloUrbana. Per al reintent, WireMock ofereix escenaris amb estat (inScenario(...).whenScenarioStateIs(STARTED)...willSetStateTo(...)), que permeten simular «falla una vegada i després funciona» i comprovar-ho amb verify(2, ...); l'exercici 2 ho desenvolupa.
Dos detalls imprescindibles: reiniciar el tallacircuits entre proves, perquè el seu estat és global al context i una prova deixaria el circuit obert per a la següent; i abaixar els llindars a application-test.yml (minimumNumberOfCalls: 5, waitDurationInOpenState: 1s) perquè la suite duri segons i no minuts. L'alternativa a WireMock és MockServer sobre Testcontainers, reprenent 06-05, útil quan es vol el mateix enfocament de contenidors de la resta de la suite.
- Comunicació asíncrona: el mínim imprescindible
Quan la resposta no fa falta per continuar (07-05), la missatgeria converteix una caiguda en un retard. Amb spring-kafka —o spring-boot-starter-amqp per a RabbitMQ—, publicar i consumir són unes poques línies:
@Component
public class ConsumidorFacturacio {
@KafkaListener(topics = "ciclourbana.lloguers", groupId = "facturacio")
@Transactional
public void alFinalitzarLloguer(LloguerFinalitzat esdeveniment) {
if (processats.existsById(esdeveniment.esdevenimentId())) {
return; // idempotència (07-05)
}
processats.save(new EsdevenimentProcessat(esdeveniment.esdevenimentId()));
serveiPagaments.cobrar(esdeveniment.lloguerId(), esdeveniment.importTotal());
}
}La publicació és simètrica —kafka.send("ciclourbana.lloguers", String.valueOf(esdeveniment.lloguerId()), esdeveniment)— amb el lloguerId com a clau de partició perquè els esdeveniments d'un mateix lloguer conservin l'ordre.
| Aspecte | Què decidir |
|---|---|
| Serialització | JSON és l'habitual; Avro o Protobuf amb esquema versionat quan el contracte ha d'evolucionar sense trencar consumidors |
| Idempotència del consumidor | Obligatòria: el lliurament és «com a mínim una vegada» |
| Reintents i cua de fallits | Amb DefaultErrorHandler i DeadLetterPublishingRecoverer, després de N intents el missatge va a ...DLT |
| Clau de partició | Garanteix l'ordre dins d'una mateixa entitat |
La cua de missatges fallits mereix un paràgraf. Sense ella, un missatge que sempre falla —un esdeveniment amb un camp corrupte— es reintenta indefinidament i bloqueja tota la partició: cap missatge posterior no es processa. Amb ella, el missatge problemàtic s'aparta a un tema .DLT per a revisió manual i el flux continua. I aquest tema cal vigilar-lo: una cua de fallits que ningú no mira és una carpeta de correu que ningú no obre.
Errors Comuns i Consells
No configurar els temps d'espera. L'error més car: un tercer lent exhaureix els fils i tomba CicloUrbana sencera.
Crear un client HTTP a cada crida. Es perd el pool de connexions i cada petició paga una negociació TLS completa.
Reintentar un POST no idempotent. Un temps d'espera exhaurit no significa que l'operació no s'executés: es cobra dues vegades.
Comptar els errors de negoci com a fallades del circuit. Mil targetes sense saldo obririen el tallacircuits i deixarien sense cobraments tota la xarxa. ignoreExceptions.
Signatura del fallbackMethod incorrecta. Resilience4j no el troba i l'excepció original s'escapa, sense cap error en compilació.
Una reserva que crida un altre servei remot. Reintrodueix exactament el problema que venia a resoldre.
Deixar escapar l'excepció del transport. HttpClientErrorException en un servei de domini acobla l'aplicació a la biblioteca HTTP.
Propagar el JWT del ciutadà a un tercer. És una filtració de credencials: cap enfora va la clau del sistema, no la de l'usuari.
Posar el tallacircuits al grup readiness. Obrir-se significa que el tercer és caigut, no que la instància estigui malalta.
Consell: un client, un tallacircuits i una mampara per destinació, perquè compartir-los fa que un tercer lent afecti crides que no hi tenen res a veure; i fixa els temps des del pressupost de latència del teu endpoint, no des del que sol trigar el remot.
Consell: prova les fallades, no només el camí feliç. Un tallacircuits que no s'ha vist obrir mai en una prova és una hipòtesi, no una protecció.
Exercicis
Exercici 1: client resilient del servei d'estacions
servei-lloguers necessita consultar a servei-estacions si la bicicleta RB-0142 està disponible abans d'iniciar un lloguer. Dissenya el client complet: interfície @HttpExchange, RestClient amb temps d'espera i propagació del JWT, configuració de Resilience4j i reserva. La consulta és de només lectura i l'endpoint de lloguer ha de respondre en menys d'un segon. Justifica cada valor i decideix què ha de passar si estacions no respon.
Exercici 2: la prova que demostra la protecció
Escriu les proves amb WireMock que demostrin, per al client de l'exercici anterior: que un 503 puntual es reintenta i acaba funcionant; que després de fallades sostingudes el circuit obre i deixa de sortir trànsit; que una resposta que triga 20 segons no bloqueja el ciutadà; i que un 404 (bicicleta inexistent) no compta com a fallada del circuit. Indica la configuració d'application-test.yml necessària.
Exercici 3: revisar un client de producció
Troba tots els problemes d'aquest codi i reescriu-lo.
@Service
public class ServeiPagaments {
@Retry(name = "pagaments", fallbackMethod = "reserva")
public String cobrar(Long lloguerId, BigDecimal importTotal) {
RestTemplate rest = new RestTemplate();
String url = "https://pagaments.ribalta.example/api/v1/pagaments?apiKey=" + apiKey;
ResponseEntity<String> r = rest.postForEntity(url,
Map.of("lloguer", lloguerId, "import", importTotal), String.class);
if (r.getStatusCode() != HttpStatus.OK) {
throw new RuntimeException("Error: " + r.getBody());
}
return this.extreureReferencia(r.getBody());
}
private String reserva(Long lloguerId) {
return null;
}
}Solucions
Solució 1
@HttpExchange(url = "/estacions", accept = "application/json")
public interface ClientEstacions {
@GetExchange("/bicicletes/{matricula}/disponibilitat")
DisponibilitatBicicleta consultarDisponibilitat(@PathVariable String matricula);
}@Bean
RestClient clientEstacions(EstacionsProperties props,
InterceptorRastreig rastreig, InterceptorJwt jwt) {
var ajustos = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofMillis(300))
.withReadTimeout(Duration.ofMillis(600));
return RestClient.builder()
.baseUrl(props.url())
.requestFactory(ClientHttpRequestFactories.get(ajustos))
.requestInterceptor(rastreig)
.requestInterceptor(jwt) // servei propi: el token relay és correcte
.build();
}resilience4j:
circuitbreaker:
instances:
estacions:
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallDurationThreshold: 500ms # al camí crític, lent = trencat
slowCallRateThreshold: 60
waitDurationInOpenState: 15s
ignoreExceptions: [com.ciclourbana.estacions.BicicletaNoTrobadaException]
retry:
instances:
estacions: { maxAttempts: 2, waitDuration: 100ms, enableRandomizedWait: true }
bulkhead:
instances:
estacions: { maxConcurrentCalls: 30, maxWaitDuration: 50ms }@CircuitBreaker(name = "estacions", fallbackMethod = "senseInformacio")
@Retry(name = "estacions")
@Bulkhead(name = "estacions")
public DisponibilitatBicicleta consultar(String matricula) {
return client.consultarDisponibilitat(matricula);
}
private DisponibilitatBicicleta senseInformacio(String matricula, Throwable causa) {
log.warn("servei-estacions no disponible ({}): es denega el lloguer de {}",
causa.toString(), matricula);
throw new ServeiEstacionsNoDisponibleException(matricula); // -> 503 amb Retry-After
}Justificació de cada valor. El pressupost és d'un segon per a tot l'endpoint, i aquesta consulta només n'és una part: d'aquí 300 ms de connexió i 600 de lectura, amb dos intents com a màxim i 100 ms de pausa, cosa que acota el pitjor cas en uns 1,6 s abans que actuï la reserva —ja fora de pressupost, i per això el circuit és agressiu—. slowCallDurationThreshold: 500ms és baix a propòsit: al camí crític, lent equival a trencat. waitDurationInOpenState: 15s és curt perquè és un servei propi que es recupera de pressa després d'un desplegament. ignoreExceptions amb la bicicleta inexistent evita que un 404 legítim compti com a fallada. I la mampara de 30 crides concurrents protegeix el pool de Tomcat.
Què passa si estacions no respon, i per què. Aquí la decisió és la contrària a la dels pagaments: es denega el lloguer amb un 503. La diferència és que el cobrament diferit tenia compensació possible —es cobra més tard— mentre que iniciar un lloguer sense saber si la bicicleta està disponible no en té: dos ciutadans es podrien endur la mateixa bicicleta, o algú llogar-ne una que és al taller. Quan la informació que falta és la que fa correcta l'operació, la degradació correcta és rebutjar. És la mateixa pregunta de l'apartat 12 amb resposta diferent, i per això es decideix cas a cas.
Solució 2
# application-test.yml — llindars baixos perquè les proves durin segons
resilience4j:
circuitbreaker:
instances:
estacions:
slidingWindowSize: 6
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 1s
slowCallDurationThreshold: 300ms
retry:
instances:
estacions: { maxAttempts: 2, waitDuration: 50ms }private static final String RUTA = "/estacions/bicicletes/RB-0142/disponibilitat";
@Test
void unaFallaPuntualEsReintentaIAcabaFuncionant() {
estacions.stubFor(get(RUTA).inScenario("r").whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503)).willSetStateTo("ok"));
estacions.stubFor(get(RUTA).inScenario("r").whenScenarioStateIs("ok")
.willReturn(okJson("{\"matricula\":\"RB-0142\",\"disponible\":true}")));
assertThat(servei.consultar("RB-0142").disponible()).isTrue();
estacions.verify(2, getRequestedFor(urlEqualTo(RUTA)));
}
@Test
void despresDeFallesSostingudesElCircuitObreINoSurtTransit() {
estacions.stubFor(get(RUTA).willReturn(aResponse().withStatus(500)));
for (int i = 0; i < 6; i++) {
assertThatThrownBy(() -> servei.consultar("RB-0142"))
.isInstanceOf(ServeiEstacionsNoDisponibleException.class);
}
assertThat(registre.circuitBreaker("estacions").getState()).isEqualTo(State.OPEN);
estacions.resetRequests();
assertThatThrownBy(() -> servei.consultar("RB-0142"))
.isInstanceOf(ServeiEstacionsNoDisponibleException.class);
estacions.verify(0, getRequestedFor(urlEqualTo(RUTA)));
}
@Test
void unaRespostaLentaNoBloquejaElCiutada() {
estacions.stubFor(get(RUTA).willReturn(okJson("{}").withFixedDelay(20_000)));
long inici = System.currentTimeMillis();
assertThatThrownBy(() -> servei.consultar("RB-0142"))
.isInstanceOf(ServeiEstacionsNoDisponibleException.class);
assertThat(System.currentTimeMillis() - inici).isLessThan(3_000);
}
@Test
void unaBicicletaInexistentNoComptaComAFallaDelCircuit() {
estacions.stubFor(get(urlPathMatching(".*/disponibilitat"))
.willReturn(aResponse().withStatus(404)));
for (int i = 0; i < 6; i++) {
assertThatThrownBy(() -> servei.consultar("RB-9999"))
.isInstanceOf(BicicletaNoTrobadaException.class);
}
assertThat(registre.circuitBreaker("estacions").getState()).isEqualTo(State.CLOSED);
}Comentaris. La primera fa servir escenaris amb estat de WireMock, l'única manera de simular «falla una vegada i després funciona», i el verify(2, ...) és el que prova el reintent; sense ell, la prova passaria igual sense cap reintent configurat. La segona conté l'assert més valuós de l'exercici: verify(0, ...) després de resetRequests() demostra que amb el circuit obert no surt ni una petició, que és precisament l'objectiu del patró. La tercera comprova que 20 segons de retard es tallen en menys de 3, cosa que prova que l'espera de lectura està configurada. I la quarta és la més subtil: verifica que un error de negoci no degrada el sistema; sense ignoreExceptions, sis consultes a una matrícula inexistent deixarien sense servei tota la xarxa de Ribalta.
Cada prova parteix d'estacions.resetAll() i registre.circuitBreaker("estacions").reset() en un @BeforeEach; sense això, l'ordre d'execució determinaria el resultat i la suite seria intermitent.
Solució 3
Nou problemes:
| # | Problema | Conseqüència |
|---|---|---|
| 1 | new RestTemplate() a cada crida |
Sense pool: negociació TLS completa per petició; i sense cap temps d'espera, així que un remot lent reté el fil indefinidament |
| 2 | L'apiKey a la URL |
Queda als logs d'accés, als proxies intermedis i a l'historial: és una credencial filtrada. Ha d'anar en una capçalera |
| 3 | @Retry sobre un POST sense clau d'idempotència |
Un cobrament es pot aplicar dues vegades si la fallada passa després de cobrar |
| 4 | RuntimeException genèrica |
El gestor de 03-06 no pot distingir «targeta sense saldo» de «passarel·la caiguda»: tot acaba en 500 |
| 5 | r.getBody() al missatge de l'excepció |
El cos d'una passarel·la de pagaments pot contenir dades sensibles, i acaba a la resposta al client |
| 6 | Signatura del fallbackMethod incorrecta |
Li falta BigDecimal importTotal i el Throwable final: Resilience4j no el troba i l'excepció s'escapa |
| 7 | La reserva retorna null |
Trasllada la fallada a un NullPointerException en un altre punt, molt més difícil de diagnosticar |
| 8 | this.extreureReferencia(...) |
Autoinvocació: irrellevant aquí perquè el mètode no està anotat, però és l'hàbit que trenca @Retry i @CircuitBreaker |
| 9 | Sense tallacircuits ni mampara | Només hi ha reintents, que davant d'una fallada sostinguda tripliquen la càrrega sobre un sistema ja caigut |
La versió corregida és la del cos de la lliçó: RestClient com a bean amb els temps de PassarelaProperties, la clau en una capçalera per defecte, la interfície @HttpExchange, el defaultStatusHandler que tradueix a PagamentRebutjatException / PassarelaNoDisponibleException, @CircuitBreaker + @Retry + @Bulkhead amb ignoreExceptions sobre els errors de negoci, la clau d'idempotència cobrament-lloguer-{id} a cada sol·licitud, i un cobramentDiferit(Long, BigDecimal, Throwable) que encua el cobrament i retorna ResultatCobrament.diferit() —mai null—.
Conclusió
El mòdul 7 acaba i CicloUrbana ha canviat de naturalesa. Va començar sent una aplicació correcta: ben construïda, ben provada i completament incapaç de viure fora del portàtil d'un desenvolupador. Acaba sent una aplicació operable. Actuator respon si és sana, quina versió s'executa i què està fallant, amb sondes de disponibilitat que un orquestrador entén. Els perfils fan que el mateix artefacte —el que va aprovar ./mvnw verify— serveixi per al portàtil i per al servidor de l'ajuntament sense recompilar-se, amb els secrets fora del repositori. Les tasques programades caduquen els lloguers oblidats de Ribalta i recalculen l'ocupació, coordinades entre instàncies; l'execució asíncrona envia el correu de confirmació sense fer esperar el ciutadà, amb el rastre i la identitat viatjant a l'altre fil. La imatge de contenidor empaqueta l'aplicació, el seu JRE i la seva zona horària en un artefacte reproduïble, amb capes que fan que reconstruir costi segons, un usuari sense privilegis i un docker-compose.yml on PostgreSQL i l'aplicació s'esperen per les seves comprovacions de salut. I aquesta última lliçó hi ha afegit la peça que faltava: CicloUrbana ja sap parlar amb el món exterior sense morir en l'intent —temps d'espera que acoten l'espera, reintents amb retrocés i jitter només allà on és segur reintentar, un tallacircuits que deixa d'insistir sobre allò que és caigut, mampares que impedeixen que un tercer lent exhaureixi els fils, i una degradació elegant que és una decisió de negoci, escrita en codi i demostrada amb proves que fingeixen la caiguda—. Sap a més quan té sentit dividir-se en microserveis i, encara més valuós, quan no. El que queda ja no és construir, sinó lliurar: la xarxa de Ribalta funciona, és observable, resisteix i està empaquetada, però continua vivint en màquines de desenvolupament. El mòdul 8, Desplegament d'Aplicacions Spring Boot, la posa per fi en mans dels ciutadans: què significa desplegar i què cal decidir abans de fer-ho, una primera plataforma senzilla amb Heroku, la infraestructura real a AWS, l'orquestració amb Kubernetes —on les sondes de 07-01 i la imatge de 07-04 encaixen per fi al seu lloc— i una canalització d'integració i lliurament continu que porti cada commit des del repositori fins a la ciutat sense que ningú toqui un servidor a mà.
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
