Calling external services

Choose an HTTP client and bound the cost of slow, failed, or repeated remote calls.

In this article

Spring Boot 4.0 uses Spring Framework 7.0, where RestTemplate is deprecated in favor of RestClient. Use RestClient for imperative calls and WebClient for non-blocking or streaming work. Neither makes a remote operation part of your database transaction.

Configure once, call through an adapter

Build reusable clients from Boot's injected builders. The builder preserves Boot customizations such as message converters and observations. The request factory owns transport behavior. This configuration fragment deliberately selects the JDK transport instead of letting classpath changes select it. Imports and the enclosing @Configuration class are omitted:

@Bean(destroyMethod = "close")
java.net.http.HttpClient catalogTransport() {
	return java.net.http.HttpClient.newBuilder()
		.connectTimeout(Duration.ofMillis(500))
		.build();
}

@Bean
RestClient catalogHttp(RestClient.Builder builder,
		@Qualifier("catalogTransport") java.net.http.HttpClient transport) {
	var factory = new JdkClientHttpRequestFactory(transport);
	factory.setReadTimeout(Duration.ofSeconds(2));
	return builder.baseUrl("https://catalog.example.com")
		.requestFactory(factory)
		.build();
}

Spring closes the Java 21 HttpClient bean on shutdown. The injected builder still supplies Boot's client customizations, but the explicit factory replaces Boot's transport selection and transport settings. The URL is illustrative. Externalize the provider URL and credentials in a real application.

Connection timeout bounds establishment of a new connection. The JDK factory's read timeout uses the JDK request-timeout mechanism. These settings do not establish a deadline for the whole business operation, including retries and application work. Other transports may measure read inactivity instead. A pooled transport can also wait for a connection before attempting a call. Bound that wait and concurrent calls separately.

This adapter method fragment assumes an injected @Qualifier("catalogHttp") RestClient http. The provider contract defines 404 as a missing product and requires a JSON body for 200:

record ProductResponse(String id, String name) {}

public Optional<ProductResponse> find(String id) {
	try {
		var product = http.get()
			.uri("/products/{id}", id)
			.accept(MediaType.APPLICATION_JSON)
			.retrieve()
			.body(ProductResponse.class);
		if (product == null) {
			throw new RestClientException("Catalog returned an empty product response");
		}
		return Optional.of(product);
	} catch (HttpClientErrorException.NotFound missing) {
		return Optional.empty();
	}
}

The adapter maps one known provider response, not every failure. With retrieve(), error statuses raise exceptions by default. exchange() gives direct response access and does not apply those status handlers.

Input or provider response Observable adapter outcome Meaning
find("p7"), 200 {"id":"p7","name":"Desk"} Optional[ProductResponse[id=p7, name=Desk]] Product found
find("absent"), 404 Optional.empty Known absence, not an outage
find("p7"), 401 HTTP client exception escapes Fix credentials, do not pretend the product is absent
find("p7"), 503 HTTP server exception escapes Provider unavailable
Connection failure or response timeout ResourceAccessException escapes Transport failed, not a provider status
200 with no body RestClientException escapes Provider contract violation

The outcomes above are illustrative, not captured network output. An application boundary can map unavailable-provider errors to its public error contract without exposing provider bodies or credentials.

HTTP service interfaces reduce repetitive request construction. This alternative interface fragment assumes separate registration backed by the same configured client:

interface CatalogClient {
	@GetExchange("/products/{id}")
	ProductResponse find(@PathVariable("id") String id);
}

The proxy does not supply a timeout, retry policy, or local transaction guarantee. Keep provider-specific statuses and data shapes behind the application adapter whichever client syntax you choose.

Separate rejection from uncertainty

Compare two attempts to charge order o42. The following timeline uses an illustrative provider, not the catalog endpoint. Times show the order of events, not measured latency.

Fig 1A timeout can hide a successful payment
STUDY MODEL
Inspect one part at a timeConnect failure
  1. Connect failure

    In the illustrated refused connection, no payment request reaches the provider.

  2. Accepted request

    The provider can accept POST o42 before its response reaches the caller.

  3. Lost response

    A caller timeout leaves the outcome unknown. It does not prove the payment failed.

  4. Reconcile

    Use operation identity, idempotency, or a status query to establish what happened before retrying an effect.

A failed connection and a lost response imply different knowledge about the effect.

How to read this model

A request terminal with separate send and return holding bays. The object is a teaching analogy. Inspecting a part highlights its role; it does not execute or simulate the software. Cabinet trays are records, workpieces are data, and latches are checks. Unless an explanation states a quantity, compartments and sample workpieces do not denote data counts. Part dimensions are not a quantitative scale.

Time Connection refused before any request bytes Timeout after provider accepts payment
t0 Caller attempts a connection Caller sends POST /payments, key pay-o42
t1 Connection cannot be established Provider commits payment p91
t2 Caller receives a transport error Response is lost. Caller times out
Known state This attempt sent no payment request Caller has no confirmed result. Provider has payment p91
Next decision Correct connectivity and apply a safe retry policy Reuse the same supported key or query the provider before deciding

A generic transport exception does not prove that the failure happened before sending. Proxies, prior attempts, and dropped responses can make the outcome uncertain. Do not classify every ResourceAccessException as "payment never happened."

This payment call fragment assumes an injected payments client, an application-defined PaymentRequest, and a provider that implements the key contract below:

payments.post()
	.uri("/payments")
	.header("Idempotency-Key", "pay-o42")
	.contentType(MediaType.APPLICATION_JSON)
	.body(new PaymentRequest("o42", 2500, "USD"))
	.retrieve()
	.toBodilessEntity();

Persist the key with the logical payment intent before the first attempt. Reuse that key and the same amount, currency, and order across retries and process restarts. A new key for each attempt defeats deduplication. An in-memory map of keys does not survive deployment.

The provider must recognize the key for this endpoint and account, handle concurrent requests with the same key, and retain the result for the retry window. After that retention window, do not assume the key still prevents another charge. Stripe's documented idempotency behavior is one concrete example, not a universal HTTP guarantee. Without a supported contract, reconcile the uncertain payment through a provider lookup or manual recovery instead of blindly repeating it.

Protect the caller's capacity

Retry only failures that the operation can safely repeat. Limit attempts and elapsed time, use backoff with jitter, and respect provider retry guidance. One layer should own retries. Retrying at several layers multiplies traffic.

A circuit breaker rejects new calls after configured failure or slow-call thresholds, then permits trial calls to detect recovery. It does not impose a concurrency limit or replace transport timeouts. A separate concurrency limit protects local capacity even while the breaker is closed. A fallback must preserve the use case's meaning, not fabricate success.

A large connection pool can overwhelm the provider. A small pool with an unbounded pending queue moves the overload into your service. Keep response buffering bounded, and close response streams when using streaming APIs.

Avoid waiting on HTTP inside a database transaction. The wait can retain a connection and locks, and a later rollback cannot undo the remote operation. Separate remote work from short database transactions, or persist an intent for durable delivery.

Continue with transactions, events and messaging, and observability.

Sources

Keep track of your reading.

Your reading progress stays in this browser.