Spring Boot Skills
GitHub
Framework 7 Core

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

Spring 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 @Component or an implementation to the interface - register the generated proxy as a bean.
  • Agent uses RestClient for Mono or Flux - use WebClientAdapter for 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.
FROM THE REPOSITORY

Examples & templates

bad-order-client.javaexamples
Source on GitHub
@Component
@HttpExchange("https://orders.internal/orders")
interface OrderClient {
    @GetExchange("/{id}") OrderDto get(@PathVariable UUID id);
}
good-order-client.javaexamples
Source on GitHub
@HttpExchange("/orders")
interface OrderClient {
    @GetExchange("/{id}") OrderDto get(@PathVariable UUID id);
}

// Register once with HttpServiceProxyFactory and a configured RestClientAdapter.
CatalogHttpClient.javatemplates
Source on GitHub
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 evidence
ADD TO YOUR AGENT

Install Boot 3 skills

claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skills