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

  1. Els clients HTTP d'Spring
  2. El client de la passarel·la de pagaments
  3. Temps d'espera: l'ajust que no es pot oblidar
  4. Interceptors: propagar el rastre i el token
  5. Traduir els errors remots al domini
  6. La interfície declarativa amb @HttpExchange
  7. Resilience4j: instal·lació i configuració
  8. Reintents
  9. Tallacircuits
  10. Limitador de taxa, mampara i temps límit
  11. L'ordre dels decoradors
  12. Degradació elegant
  13. Observabilitat de la resiliència
  14. Provar les fallades amb WireMock
  15. Comunicació asíncrona: el mínim imprescindible
  16. Errors Comuns i Consells
  17. Exercicis

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. La interfície declarativa amb @HttpExchange

Amb 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).

  1. 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.

  1. 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.

  1. 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:

  1. 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ó.
  2. Ha de ser a la mateixa classe i pot ser private.
  3. Poden ser-ne diversos, especialitzats per tipus d'excepció; guanya el més específic.
  4. 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.

  1. 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.

  1. 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:

Bulkhead ( TimeLimiter ( RateLimiter ( CircuitBreaker ( Retry ( crida real ) ) ) ) )

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.

  1. 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.

  1. 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/retries

Compte 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.

  1. 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.

  1. 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

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats