HTTP Interface Clients
Use when calling external HTTP APIs from Spring Boot 3 / Spring Framework 6 with declarative HttpExchange interfaces. Covers manual proxy registration, RestClient versus WebClient, and timeout and error handling conventions.
View SKILL.mdSpring Framework 6 supports @HttpExchange interfaces, but Boot 3 does not provide Boot 4's
@ImportHttpServices group auto-registration. Build the client adapter explicitly.
@HttpExchange("/orders")
interface OrderApiClient {
@GetExchange("/{id}")
OrderDto get(@PathVariable UUID id);
}
@Configuration
class ClientConfig {
@Bean
OrderApiClient orderApiClient(RestClient.Builder builder,
@Value("${clients.orders.base-url}") String baseUrl) {
RestClient client = builder.baseUrl(baseUrl).build();
HttpServiceProxyFactory factory =
HttpServiceProxyFactory.builderFor(RestClientAdapter.create(client)).build();
return factory.createClient(OrderApiClient.class);
}
}
Use WebClientAdapter for reactive interfaces returning Mono or Flux. Configure base URLs,
timeouts, authentication, and error translation in the client adapter layer rather than in
controllers or domain services. Prefer one factory/configurer per external service.
Transport failure contract
Define an adapter policy before adding retry annotations. The companion
CatalogHttpClient implements the
CatalogRetryService.CatalogTransport port from the resilience-retry skill and uses
explicit JDK connect/read timeouts. It is a small imperative adapter example, not a reason
to replace an existing HTTP interface or Boot-managed client.
| Failure | Example policy for an idempotent read |
|---|---|
| 401/403, malformed request, missing resource | Surface the domain-appropriate error; no blind retry |
| 502/503/504, connect/read timeout | Translate to the bounded retry policy |
| 429 with Retry-After | Schedule only within the caller's budget; not implemented by this example |
| Cancellation or interruption | Propagate immediately, preserving interruption |
| Invalid response body or TLS/certificate error | Diagnose; do not repeatedly retry by default |
For POST/payment calls, preserve the same idempotency key and request bytes across retries; a timeout does not prove the server did nothing. Never forward inbound Authorization to an arbitrary destination: configure credentials per trusted service. Do not allow an end user to choose the base URL. Avoid logging tokens or raw upstream error bodies.
Test the real client adapter against a stub HTTP server, including permanent status codes, transient-then-success responses, malformed bodies, delayed responses and cancellation. Keep connect, read, pool-acquisition and total deadlines distinct. When using WebClient, apply a total timeout around the whole retry sequence and avoid blocking the event loop.
See Spring REST clients.
Verification scope
The repository fixture targets Boot 3.5.16 with Java 17/21 and checks the imperative catalog adapter against a local HTTP server; not every declarative group-registration option. Check the coverage matrix before treating another minor version or an illustrative snippet as verified.
Gotchas
- Agent uses
@ImportHttpServices- that Boot 4 registration API is not available in Boot 3. - Agent adds
@Componentor an implementation to the interface - register the generated proxy as a bean. - Agent uses
RestClientforMonoorFlux- useWebClientAdapterfor reactive return types. - Agent hard-codes remote hosts in annotations - keep URLs in configuration.
- Agent lets transport errors leak through the domain - translate them in the client adapter.
- Agent creates a new client per request - configure and reuse a singleton proxy.
Examples & templates
bad-order-client.javaexamples
@Component
@HttpExchange("https://orders.internal/orders")
interface OrderClient {
@GetExchange("/{id}") OrderDto get(@PathVariable UUID id);
}good-order-client.javaexamples
@HttpExchange("/orders")
interface OrderClient {
@GetExchange("/{id}") OrderDto get(@PathVariable UUID id);
}
// Register once with HttpServiceProxyFactory and a configured RestClientAdapter.CatalogHttpClient.javatemplates
package com.example.clients;
import java.net.ConnectException;
import java.net.URI;
import java.net.SocketTimeoutException;
import java.net.http.HttpClient;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CancellationException;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import com.example.clients.CatalogRetryService.CatalogTransport;
import com.example.clients.CatalogRetryService.TransientCatalogException;
/** Error classification belongs in the transport adapter, not in controllers. */
public final class CatalogHttpClient implements CatalogTransport {
private final RestClient client;
public CatalogHttpClient(RestClient client) { this.client = client; }
public static CatalogHttpClient create(URI baseUrl, Duration connectTimeout, Duration readTimeout) {
if (connectTimeout == null || readTimeout == null || connectTimeout.isNegative()
|| connectTimeout.isZero() || readTimeout.isNegative() || readTimeout.isZero()) {
throw new IllegalArgumentException("Positive transport timeouts required");
}
var http = HttpClient.newBuilder().connectTimeout(connectTimeout).build();
var factory = new JdkClientHttpRequestFactory(http);
factory.setReadTimeout(readTimeout);
return new CatalogHttpClient(RestClient.builder().baseUrl(baseUrl.toString()).requestFactory(factory).build());
}
@Override public String fetch(UUID id) {
try {
return client.get().uri("/catalog/{id}", id).retrieve().body(String.class);
} catch (HttpServerErrorException failure) {
int status = failure.getStatusCode().value();
if (status == 502 || status == 503 || status == 504) {
throw new TransientCatalogException(failure);
}
throw failure;
} catch (ResourceAccessException failure) {
boolean transientFailure = false;
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof InterruptedException) {
Thread.currentThread().interrupt();
throw new CancellationException("Interrupted");
}
transientFailure |= cause instanceof ConnectException
|| cause instanceof SocketTimeoutException || cause instanceof HttpTimeoutException;
}
if (transientFailure) throw new TransientCatalogException(failure);
throw failure;
}
}
}Verification scope
- Compiled sources
- CatalogHttpClient, both trees
- Runtime checks
- Real stub HTTP server: transient/permanent status classification and read timeout
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 3 skills
Install Boot 3 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-3-skills@spring-boot-skills