A customer opens the delivery panel in an online shop. The product page works, the basket is intact, but the shipping provider is having a bad morning. Should the shop freeze, retry repeatedly, or quietly display a shipping cost of zero?
None of those is a good business outcome. The useful response is more precise: keep the parts of the application that still work available, state that the shipping quote is temporarily unavailable, and prevent the failing integration from absorbing more requests than necessary.
That is where the Circuit Breaker pattern earns its place. The interesting design work lies in deciding which failures it observes, where the boundary belongs, and what its fallback is allowed to mean. This article builds that boundary with Spring Cloud and a runnable shipping-quote example.
Why this pattern deserves attention now
Three developments shape the current Spring Cloud landscape. As checked on October 15, 2026, the Spring project page lists Spring Cloud 2025.1.3 as the current stable release. Its compatibility table maps the 2025.1 train to Spring Boot 4.0 and, from Cloud 2025.1.2 onward, Boot 4.1. Use the release-train BOM to keep the Cloud components aligned. Spring Cloud project and compatibility table
The 2026.0.0-M1 preview, announced on September 24, targets Spring Boot 4.2.0-M2. Its changes include Resilience4j 2.4.0 and a migration of Gateway retry support from Reactor Addons to Spring Framework Core Retry. These are useful developments to evaluate, but a milestone is a preview. Our executable example stays on the stable train. Spring Cloud 2026.0.0-M1 announcement
Meanwhile, Spring Cloud OpenFeign is feature-complete, and its documentation recommends migrating to Spring HTTP Service Clients. The example therefore uses an annotated HTTP interface backed by RestClient, with Spring Cloud CircuitBreaker around the application’s outbound operation. OpenFeign project guidance, Spring HTTP clients
These sources describe product direction, not a popularity survey. My editorial choice for the most useful practical topic is the combination of modern HTTP clients and explicit failure semantics. It fits Homann Software’s focus on Java engineering, architecture, and verifiable behavior. A release upgrade can change an API; it cannot decide whether an unavailable shipping quote may become a customer promise.
Start with the use case and its business rule
The use case is deliberately small: request a shipping quote for a German five-digit postcode. A successful result contains a price in euro cents and the currency EUR. The example does not place an order, reserve inventory, or authorize a payment.
Our rule is that an unavailable quote has no price. The UI may let the customer continue browsing, but a purchasing workflow must require a valid quote before committing a total. A business might separately authorize a fixed shipping tariff; that would be an explicit pricing policy, with its own tests. An arbitrary zero in an exception handler would silently introduce that policy through a technical failure.
The application exposes two outcomes: LIVE with a verified quote, or UNAVAILABLE with null price and currency. The HTTP adapter returns 200 for LIVE and 503 for UNAVAILABLE, with Cache-Control: no-store. A larger page-composition API could represent the delivery panel as an unavailable subsection while still returning its other content. That is a different HTTP contract and should be designed deliberately.
The application owns the meaning of a fallback. The client owns HTTP transport. The web adapter owns the public response.
What a circuit breaker actually controls
A circuit breaker observes completed call outcomes. In the normal CLOSED state it permits calls. When enough recorded calls meet the configured failure threshold, it opens and rejects subsequent calls locally. After a waiting period, HALF_OPEN allows a limited number of probes to determine whether traffic can resume. Resilience4j also supports slow-call thresholds and additional administrative states; this example uses the three normal states and a failure-rate threshold. Resilience4j circuit-breaker model
That leaves several separate responsibilities. A timeout limits waiting at a particular layer. A bulkhead limits concurrent work or queueing. A retry performs another attempt. A circuit breaker decides whether an attempt should be admitted based on recent outcomes. These mechanisms can cooperate, but adding a breaker does not establish a concurrency limit or make a write safe to retry.
Our demo window contains four outcomes, requires four recorded calls, and opens at a failure rate of 50 percent. Two successes followed by two failures therefore open it, as do four failures. These deliberately small numbers make behavior easy to exercise. They are tutorial settings, not production recommendations.
The state-transition tests move the breaker into HALF_OPEN explicitly. They verify probe behavior without pretending to benchmark the waiting-period timer.
Build a modern HTTP boundary
The download contains a complete Maven project using Java 21, Spring Boot 4.1.1 and Spring Cloud 2025.1.3. Cloud’s BOM resolves Spring Cloud CircuitBreaker 5.0.3 and Resilience4j 2.3.0 in this build. Those are the tested versions; the preview’s Resilience4j upgrade is not part of our test claim.
The project uses spring-boot-starter-webmvc, spring-cloud-starter-circuitbreaker-resilience4j, Actuator, and the Boot test starter. The small HTTP interface is standard Spring Framework code:
package com.homannsoftware.shipping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
public interface ShippingApi {
@GetExchange("/quotes/{postcode}")
Quote quote(@PathVariable("postcode") String postcode);
record Quote(Integer cents, String currency) {}
}
The interface says how to request data, without attaching fallback behavior to every generated client call. Spring’s HTTP Service Client proxy adapts it to RestClient. The configuration pins the underlying JDK HTTP client and sets explicit transport timeouts:
@Configuration(proxyBeanMethods = false)
public class ShippingConfiguration {
@Bean(destroyMethod = "close")
HttpClient shippingHttpClient() {
return HttpClient.newBuilder().connectTimeout(Duration.ofMillis(300))
.followRedirects(HttpClient.Redirect.NEVER).build();
}
@Bean
ShippingApi shippingApi(HttpClient shippingHttpClient,
@Value("${shipping.base-url}") String baseUrl,
@Value("${shipping.read-timeout:700ms}") Duration readTimeout) {
var requests = new JdkClientHttpRequestFactory(shippingHttpClient);
requests.setReadTimeout(readTimeout);
var client = RestClient.builder().baseUrl(baseUrl).requestFactory(requests).build();
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(client))
.build().createClient(ShippingApi.class);
}
}
The connection timeout is 300 milliseconds. The request factory’s read timeout is 700 milliseconds in the application and 300 milliseconds in the timeout test. These values are illustrative and are not a proven end-to-end checkout deadline. Real latency budgets must account for connection establishment, response handling, any queueing, other dependencies, and the caller’s remaining time.
This manually created RestClient keeps the transport choices visible. A production application can build on Boot’s configured RestClient builder to retain its standard customizations and observation setup. Do not assume that adding Actuator automatically gives this manually created client all the same tracing conventions.
Make fallback behavior selective
The application obtains one named breaker, shippingQuote, from Spring Cloud’s factory. Local input validation runs before it, so an invalid postcode never becomes a supplier failure. The protected operation includes validation of the returned quote:
public View quote(String postcode) {
if (postcode == null || !postcode.matches("[0-9]{5}")) {
throw new IllegalArgumentException("A five-digit postcode is required");
}
return breaker.run(() -> {
var q = api.quote(postcode);
if (q == null || q.cents() == null || q.cents() < 0 || !"EUR".equals(q.currency())) {
throw new IllegalStateException("Invalid shipping quote contract");
}
return new View(Status.LIVE, q.cents(), q.currency());
}, ShippingQuotes::fallback);
}
The returned record is checked for a present, non-negative price and EUR currency. Notice the nullable Integer in the transport record: a missing JSON price must not become Java’s primitive default of zero. An explicit supplier quote of zero remains valid; an absent price is a contract error. This is a compact example of an Anti-Corruption Layer: the adapter boundary prevents an external representation from becoming a trusted business value without validation. A production price model would normally use a dedicated money type, supplier identity, and any required validity period.
The fallback only converts known availability failures and an open circuit into UNAVAILABLE:
private static View fallback(Throwable failure) {
if (failure instanceof HttpServerErrorException
|| failure instanceof ResourceAccessException
|| failure instanceof CallNotPermittedException) {
return View.unavailable();
}
if (failure instanceof RuntimeException runtime) { throw runtime; }
throw new IllegalStateException("Unexpected shipping failure", failure);
}
HTTP 5xx responses, resource-access failures such as the tested read timeout, and locally rejected calls become an explicit unavailable result. Authentication errors, other 4xx responses, malformed contracts, and unexpected runtime failures propagate. Resource-access errors can also reveal persistent connectivity or TLS configuration problems; they still need diagnosis even though the user-facing result is unavailable.
The sample ignores HttpClientErrorException in breaker statistics. That prevents the demonstrated 400 and 401 responses from opening a circuit shared with otherwise valid requests. It also means 429 is not treated as a retry or availability signal here. If the supplier uses rate-limit responses, define a separate policy around its Retry-After contract and your request budget. Authentication failures should trigger operational attention even when excluded from availability statistics.
An open circuit avoids another provider request. The fallback preserves the absence of a usable quote.
Keep the execution model explicit
This is a blocking HTTP example. It uses the HTTP client’s timeout controls and disables Spring Cloud’s TimeLimiter and executor delegation, plus automatic bulkhead support:
shipping:
base-url: http://localhost:9090
read-timeout: 700ms
spring:
cloud:
circuitbreaker:
resilience4j:
disable-time-limiter: true
disable-thread-pool: true
bulkhead:
resilience4j:
enabled: false
resilience4j:
circuitbreaker:
instances:
shippingQuote:
slidingWindowType: COUNT_BASED
slidingWindowSize: 4
minimumNumberOfCalls: 4
failureRateThreshold: 50
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 2
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException
management:
endpoints:
web:
exposure:
include: health,metrics
Why show these choices? Disabling the TimeLimiter alone does not imply that execution stays on the calling thread. During development, our HTTP exceptions reached the fallback wrapped in an ExecutionException while executor delegation remained enabled. The final configuration disables that thread pool too, and a test verifies execution on the calling thread. The fallback classifier above is written for this execution model. If you enable executor delegation or a TimeLimiter, revisit exception wrapping, interruption, context propagation, and classification together.
Spring documents the TimeLimiter switch and property precedence; instance configuration takes precedence over group and global configuration. It also documents automatic bulkhead wrapping when the bulkhead library is present. Our explicit switches prevent an accidental second execution policy from obscuring the example. TimeLimiter and configuration precedence, Bulkhead integration
This leaves a deliberate limitation: the sample has no concurrency cap. Before enough outcomes have been recorded to open the circuit, a burst can still send many requests to the supplier. For a production boundary, select and test a bulkhead that fits the workload. Virtual threads can make blocking code economical, but they do not increase the supplier’s capacity. Thread-pool bulkheads introduce their own sizing and queueing choices; semaphore bulkheads constrain admitted concurrency. Spring Cloud bulkhead configuration
There is also no application retry. If a retry is later justified, put one owner in charge, restrict it to suitable operations and failures, and include attempts and backoff in the overall deadline. Retrying at the gateway, client and service layers can multiply supplier work. Writes additionally need a durable idempotency contract; a circuit breaker cannot establish one.
What the executable tests prove
The project starts a real Spring Boot web application on an ephemeral port and uses a local JDK HTTP server as the shipping provider. Calls traverse the generated HTTP proxy, RestClient, the JDK transport, and the configured Spring Cloud breaker. The tests do not replace the breaker with a mock.
All 18 tests passed. They cover live quote conversion; explicit unavailable data with one supplier attempt; the minimum sample size; opening at the mixed success/failure threshold; rejection without another HTTP request; successful and failed HALF_OPEN probes; excluded 400 errors; propagation of 401 errors; validation before network access; rejection of negative and missing prices and missing currency; an explicit zero-priced quote; a real delayed-response timeout; controller status and cache headers; public HTTP responses for LIVE and UNAVAILABLE; and execution on the calling thread.
The timeout test gives the provider a 1.5-second response delay and checks that the unavailable result completes within a broad five-second test guard. It is a functional timeout check, not a latency benchmark. The recovery tests force HALF_OPEN; they do not verify the wall-clock wait. There is no claim about sustained load, multiple instances, TLS failures, rate limits, production dashboards, or the supplier’s real API.
Download the complete tested Maven example. With JDK 21 or newer and Maven installed, extract the archive and run:
cd examples
mvn test
mvn spring-boot:run
The default supplier URL is http://localhost:9090; the application endpoint is http://localhost:8080/shipping/55116. The tests provide their own supplier and require no external account. For a manual LIVE response, start the included stub in another terminal:
python stub_provider.py
Start the stub with --status 503 to exercise unavailability. Restarting the stub changes provider behavior; the application’s breaker still follows its own state. No credentials or real shipping requests are involved.
Where this belongs in a production design
Name breakers after meaningful dependency operations with shared failure characteristics. One breaker for every URL, tenant or postcode creates unbounded state and metric cardinality. One breaker for unrelated suppliers can let one outage suppress healthy work. Here, shippingQuote is deliberately stable and process-local: each application replica would maintain its own observations and probes.
Observe both technical outcomes and business consequences. Track breaker transitions, rejected calls, supplier errors and latency, and the rate of UNAVAILABLE responses. A fallback can make a technical call appear handled while the business capability remains impaired. Do not turn every optional supplier failure into an application liveness failure and trigger restart loops. Decide separately how dependency health should affect readiness and traffic admission.
Record the decision in an ADR: which capability may degrade, which failures qualify, why 4xx responses are excluded, who owns retries, what deadline applies, and which signals require intervention. The included ADR also records what the example leaves for production work. That keeps configuration connected to a business decision and a testable responsibility.
An architectural pattern is useful when it makes failure behavior more understandable. In this example the shop can tell the truth: the shipping quote is unavailable, there is no price to rely on, and repeated calls will not immediately hammer the same failing dependency. Spring Cloud supplies the mechanism. The application still owns the promise it makes to its customer.