Getting started · rest-in-peace + rest-in-peace-spring-boot-starter

One client interface, every way to wire it up.

Every feature below works identically no matter how the client was constructed — a JDK dynamic proxy, a compile-time-generated class, or a Spring bean all resolve through the same RIP.getClient(...) choke point. What differs is how you configure and inject the client. Each entry shows the plain-Java form first, then every documented alternative — a different overload, a default, a collection form, or the Spring Boot starter's own way of expressing the same thing.

Amber snippets run in plain Java (or any JVM language). Green snippets are specific to the rest-in-peace-spring-boot-starter module. See the README for Maven/Gradle coordinates and the Javadoc for the full API surface.

REST-in-peace vs. Retrofit, Feign/OpenFeign, and RestTemplate/WebClient

How it compares

Placing REST-in-peace against the declarative Java HTTP clients you're probably already evaluating it next to. This is a scope comparison, not a maturity claim — Retrofit and Feign are both more established and more widely adopted today.

REST-in-peace vs. Retrofit, Feign/OpenFeign, and Spring's RestTemplate/WebClient
REST-in-peaceRetrofitFeign / OpenFeignRestTemplate/WebClient
Declarative @RestClient-style interfaces✅✅✅✅ via Spring HTTP Interfaces (@HttpExchange)
Pagination helper (@Paginated → Page<T>/Stream<T>; Flux<T> via the separate rest-in-peace-reactor module)✅ built in❌ roll your own❌ roll your own❌ roll your own
Circuit breaker / bulkhead✅ built in❌ wire in Resilience4j yourself❌ standalone Feign: wire in feign-resilience4j yourself; Spring Cloud OpenFeign: add the CircuitBreaker starter + a property flag❌ wire in Resilience4j yourself
HTTP-aware response caching (Cache-Control/ETag/stale-while-revalidate)✅ built in❌ configure OkHttp's cache + handle headers yourself❌ roll your own❌ roll your own
Bundled local test server✅ MockRestServerOkHttp's MockWebServer (separate artifact)needs WireMock (separate)Spring's bundled MockRestServiceServer (a mock, not a real local server)
OpenAPI spec → client interface generator✅ bundled OpenApiClientGeneratorseparate OpenAPI Generator templateseparate OpenAPI Generator templateseparate OpenAPI Generator template
Compile-time, reflection-free dispatch✅ optional annotation processorruntime dynamic proxy onlyruntime dynamic proxy onlyruntime proxy (HttpServiceProxyFactory)
Pluggable reactive return typesCallAdapter SPI; Mono<T>/Flux<T> via rest-in-peace-reactor✅ via separate adapter artifactsmainly synchronous✅ native (WebClient only)

See the README's full "How it compares" section ↗ for the reasoning behind each row.

Declaring a client

Every client starts as an annotated interface. Where its base URL comes from is the first thing that varies by environment — and by whether Spring is involved.

@RestClient + @BaseUrl

A fixed base URL, known at compile time. Methods write relative paths against it.

Basic
@RestClient
@BaseUrl("https://api.example.com")
interface UserApi {
    @GET("/users/{id}")
    User getUser(@PathParam("id") String id);
}

UserApi api = RIP.getClient(UserApi.class);
Alternate · default method invoked as ordinary Java, never as an HTTP call
default Optional tryGetUser(String id) {
    try {
        return Optional.of(getUser(id));
    } catch (RestInPeaceHttpException e) {
        return Optional.empty();
    }
}

Base URL resolved at runtime

@BaseUrl must be a compile-time constant, so an env-dependent host bypasses it entirely.

Alternate · env var wins over @BaseUrl
@RestClient
interface UserApi {
    @GET("/users/{id}")
    User getUser(@PathParam("id") String id);
}

UserApi api = RIP.getClient(UserApi.class, System.getenv("USER_API_BASE_URL"));
Alternate · full config base URL + timeout + proxy together
UserApi prodApi = RIP.getClient(UserApi.class, RipClientConfig.builder()
        .baseUrl(prodBaseUrl)
        .connectTimeoutMillis(2_000)
        .readTimeoutMillis(10_000)
        .build());
Spring · property-resolved
@RestClient(baseUrlProperty = "user-api.base-url")
interface UserApi {
    @GET("/users/{id}")
    User getUser(@PathParam("id") String id);
}
user-api:
  base-url: https://api.example.com
@SpringBootApplication
@EnableRestInPeaceClients
class Application { }

@Service
class Orders {
    Orders(UserApi userApi) { ... }   // injected — zero @Bean method
}

HTTP methods & URLs

One verb annotation per method, plus two ways to build the URL a request hits.

Method annotations

Exactly one of @GET @POST @PUT @PATCH @DELETE @HEAD @OPTIONS per method — none or more than one fails validation.

Basic
@GET("https://api.example.com/items/{id}")
String getItem(@PathParam("id") String id);

@DELETE("https://api.example.com/items/{id}")
void deleteItem(@PathParam("id") String id);

@PathParam

Fills a {placeholder} in the URL. Values are percent-encoded, so a stray / or ? never breaks the route.

Basic
@GET("https://api.example.com/items/{id}")
String getItem(@PathParam("id") String id);
❌ Mistake URL placeholder with no matching @PathParam
@GET("https://api.example.com/items/{id}")
String getItem(String id);   // missing @PathParam("id")

Fails validation: "has path param 'id' in its URL that is not annotated on any parameter with @PathParam." There's no implicit binding by position — every {placeholder} needs an explicit @PathParam.

@Url

For a URL that isn't a fixed template — a pagination next link, a HATEOAS action link.

Basic · pagination
@GET
Page nextPage(@Url String url);

Page page = itemApi.firstPage();
while (page.next != null) {
    page = itemApi.nextPage(page.next);
    process(page.items);
}
❌ Mistake @Url combined with a static URL template
@GET("https://api.example.com/items")
String get(@Url String url);

Fails validation: "has both a @Url parameter and a static URL ... - remove one or the other." Leave the method annotation's value empty (just @GET) when using @Url.

Query params & headers

Fixed-by-name params, open-ended maps for names known only at runtime, and headers that never change.

@QueryParam / @HeaderParam

required throws at call time with no value; defaultValue fills in for null.

Basic · required + default
@GET("https://api.example.com/items")
String search(@QueryParam(value = "q", required = true) String query,
               @QueryParam(value = "page", defaultValue = "1") Integer page);
Alternate · collection repeats ?tag=a&tag=b
@GET("https://api.example.com/items")
String search(@QueryParam("tag") List tags);
Alternate · header
@GET("https://api.example.com/items")
String search(@HeaderParam(value = "Authorization", required = true) String token);
❌ Mistake calling a required param with null
api.search(null, 1);

Throws RestInPeaceException: "Missing required value for param 'q'." — required = true is enforced on every call, not just checked once at validation time.

@QueryMap / @HeaderMap

For names not known until runtime — an open-ended filter set, caller-supplied headers. One entry becomes one param; null entries are skipped, not thrown; a Collection entry value repeats the param once per element.

Basic · dynamic filters
@GET("https://api.example.com/search")
String search(@QueryParam("q") String query, @QueryMap Map filters);
Alternate · dynamic headers
@GET("https://api.example.com/users/{id}")
User getUser(@PathParam("id") String id, @HeaderMap Map extraHeaders);
❌ Mistake two @QueryMap parameters on one method
String search(@QueryMap Map<String, Object> a, @QueryMap Map<String, Object> b);

Fails validation: "has more than one parameter annotated with @QueryMap." Merge into one Map before calling instead.

@Headers

Fixed headers that are always the same value — split on the first :, whitespace trimmed.

Basic @HeaderParam/@HeaderMap wins on a name clash
@GET("https://api.example.com/users")
@Headers({ "Cache-Control: no-cache", "X-Api-Version: 2" })
List listUsers();
❌ Mistake a space instead of a colon
@Headers("X-Api-Version 2")

Fails validation: "has a @Headers entry 'X-Api-Version 2' with no ':' - expected 'Name: Value'." The single most common typo here.

Request bodies

Three mutually-exclusive body encodings — JSON/raw, multipart, and form-urlencoded — each valid only on POST/PUT/PATCH/DELETE.

@Body

A String goes as-is; anything else is JSON-serialized.

Basic · POJO
@POST("https://api.example.com/items")
String createItem(@Body Item item);
Alternate · raw JSON string
@POST("https://api.example.com/items/raw")
String createRaw(@Body String rawJson);
Alternate · non-JSON body an explicit Content-Type wins over the application/json default
@POST("https://api.example.com/orders")
@Headers({ "Content-Type: application/xml" })
String createOrder(@Body Order order);
❌ Mistake @Body on a GET
@GET("/items")
Item search(@Body SearchCriteria criteria);

Fails validation: "is annotated with @Body but HTTP method GET does not support a request body." Use @QueryParam/@QueryMap for GET filter criteria instead.

@Multipart / @Part / @PartMap

File uploads and classic form POSTs. File, byte[], and InputStream parts all become file parts; a plain String becomes a form field.

Basic · fixed parts
@POST("https://api.example.com/users/{id}/avatar")
@Multipart
String uploadAvatar(@PathParam("id") String id,
        @Part("caption") String caption, @Part("file") File avatar);
Alternate · byte[] needs a filename
@POST("https://api.example.com/users/{id}/avatar")
@Multipart
String uploadAvatarBytes(@PathParam("id") String id,
        @Part(value = "file", fileName = "avatar.png") byte[] avatarBytes);
Alternate · dynamic parts
@POST("https://api.example.com/uploads")
@Multipart
String upload(@PartMap Map parts);

Map parts = new LinkedHashMap<>();
parts.put("caption", "vacation photo");                    // plain field
parts.put("thumb", photoBytes);                             // filename defaults to "thumb"
parts.put("file", PartValue.of(photoBytes, "photo.jpg"));   // explicit filename
api.upload(parts);
Alternate · progress
@POST("https://api.example.com/users/{id}/avatar")
@Multipart
String uploadAvatar(@PathParam("id") String id, @Part("file") File avatar,
        UploadProgressListener onProgress);

api.uploadAvatar("42", avatar, (field, sent, total) ->
        System.out.printf("%s: %d / %d%n", field, sent, total));
❌ Mistake @Part without the method-level @Multipart
@POST("https://api.example.com/items")
String create(@Part("file") File f);   // missing @Multipart

Fails validation: "has a @Part parameter but is not annotated with @Multipart." The single most common mistake with this feature — the two always travel together.

@FormUrlEncoded / @Field / @FieldMap

OAuth token endpoints and classic HTML forms — application/x-www-form-urlencoded, not JSON.

Basic
@POST("https://api.example.com/oauth/token")
@FormUrlEncoded
String getToken(@Field("grant_type") String grantType, @Field("client_id") String clientId);
Alternate · dynamic fields
@POST("https://api.example.com/search")
@FormUrlEncoded
String search(@FieldMap Map filters);
❌ Mistake @FormUrlEncoded combined with @Body
@POST("https://api.example.com/items")
@FormUrlEncoded
String create(@Body Item item, @Field("name") String name);

Fails validation: "is annotated with @FormUrlEncoded and also has a @Body parameter - use one or the other." A method has exactly one body-encoding strategy.

Return types

The declared return type is the whole decoding contract — no separate converter registration.

String / POJO / void

Basic · all three
@GET("https://api.example.com/users/{id}")
String getUserRaw(@PathParam("id") String id);   // raw response body

@GET("https://api.example.com/users/{id}")
User getUser(@PathParam("id") String id);        // JSON deserialized into User

@POST("https://api.example.com/events")
void fireEvent(@Body Event event);               // response discarded

Generic collections — List<T>

Decoded element-by-element into the declared type parameter, not left as raw maps. Also works inside RipResponse<T>/CompletableFuture<T>.

Basic
@GET("https://api.example.com/users")
List<User> listUsers();
Alternate · wrapped
@GET("https://api.example.com/users")
RipResponse<List<User>> listUsersWithResponse();

@GET("https://api.example.com/users")
CompletableFuture<List<User>> listUsersAsync();
❌ Mistake a raw List with no type parameter
@SuppressWarnings("rawtypes")
List listUsers();

Compiles, but decodes as a plain List of LinkedTreeMaps, not Users — ClassCastException the moment you treat an element as a User. No validation error for this one; always declare the type parameter.

Binary downloads: byte[] and File

byte[] buffers the whole response; File streams straight to disk for large payloads.

Basic · byte[]
@GET("https://api.example.com/reports/{id}/pdf")
byte[] downloadReport(@PathParam("id") String id);
Alternate · streamed to File
@GET("https://api.example.com/reports/{id}/pdf")
File downloadReport(@PathParam("id") String id, @Destination File target);

File pdf = reportApi.downloadReport("42", new File("/tmp/report.pdf"));
Alternate · progress
@GET("https://api.example.com/reports/{id}/pdf")
File downloadReport(@PathParam("id") String id, @Destination File target,
        DownloadProgressListener onProgress);

reportApi.downloadReport("42", target, (sent, total) ->
        System.out.printf("%d / %d%n", sent, total));   // total is -1 with no Content-Length
❌ Mistake File return type with no @Destination
@GET("https://api.example.com/reports/{id}/pdf")
File downloadReport(@PathParam("id") String id);   // missing @Destination

Fails validation: "returns File but has no @Destination parameter to write the response to." Unlike byte[], File needs somewhere to write the bytes.

RipResponse<T>

When a call needs the status code or a header too, not just the body.

Basic
@GET("https://api.example.com/users/{id}")
RipResponse getUser(@PathParam("id") String id);

RipResponse response = userApi.getUser("42");
response.getStatus();               // 200
response.getHeader("ETag");         // first value, case-insensitive
response.getHeaders();              // Map>
User user = response.getBody();
❌ Mistake RipResponse<File>
RipResponse<File> downloadReport(@PathParam("id") String id);

Fails validation: "returns RipResponse<File>, which is not supported - use a plain File return type with @Destination instead." RipResponse<byte[]> works fine if you need both the bytes and the headers.

Async — CompletableFuture<T>

Same decoding rules as a synchronous T, just non-blocking. Combines with byte[], File, and RipResponse<T> too.

Basic
@GET("https://api.example.com/users/{id}")
CompletableFuture getUserAsync(@PathParam("id") String id);

userApi.getUserAsync("42").thenAccept(user -> System.out.println(user.name));
Alternate · keep the JVM exiting not the default — call before building any client, covers RipClientConfig-backed ones too
RIP.useDaemonThreadsForAsync();   // call once at startup, before any async call
Spring · default flipped called for you, unconditionally, at context startup
// A long-running Spring app has no reason to want non-daemon I/O threads
// outliving its own shutdown — the starter's auto-configuration calls
// RIP.useDaemonThreadsForAsync() automatically. No property to opt out;
// exclude RestInPeaceAutoConfiguration if you truly need the plain-Java default.

Pluggable CallAdapter SPI

For a return type RIP has no built-in support for — rest-in-peace-reactor's Mono<T>/Flux<T>, or your own — register a CallAdapterFactory that transforms the exact CompletableFuture RIP's async dispatch already produced. Zero hard dependency on whatever it adapts to.

The SPI
public interface CallAdapter<T> {
    Type responseBodyType();                       // what to decode the response body into
    T adapt(CompletableFuture<Object> delegate);    // wraps the call RIP already dispatched
}

public interface CallAdapterFactory {
    Optional<CallAdapter<?>> get(Method method);    // recognize a method's return type, or decline
}
Full example · Optional<T> a 404 becomes Optional.empty() instead of a thrown exception
public final class OptionalCallAdapterFactory implements CallAdapterFactory {
    @Override
    public Optional<CallAdapter<?>> get(Method method) {
        if (method.getReturnType() != Optional.class) return Optional.empty();
        Type genericReturnType = method.getGenericReturnType();
        if (!(genericReturnType instanceof ParameterizedType)) return Optional.empty();
        Type innerType = ((ParameterizedType) genericReturnType).getActualTypeArguments()[0];
        return Optional.of(new CallAdapter<Optional<Object>>() {
            public Type responseBodyType() { return innerType; }
            public Optional<Object> adapt(CompletableFuture<Object> delegate) {
                try {
                    return Optional.ofNullable(delegate.join());
                } catch (CompletionException e) {
                    if (e.getCause() instanceof RestInPeaceHttpException
                            && ((RestInPeaceHttpException) e.getCause()).getStatus() == 404) {
                        return Optional.empty();
                    }
                    throw e;
                }
            }
        });
    }
}

RIP.addCallAdapterFactory(new OptionalCallAdapterFactory());   // once, at startup

@GET("/users/{id}")
Optional<User> findUser(@PathParam("id") String id);
❌ Mistake claiming a return type RIP already handles
public Optional<CallAdapter<?>> get(Method method) {
    if (method.getReturnType() == byte[].class) {
        return Optional.of(myByteArrayAdapter);   // never actually consulted
    }
    return Optional.empty();
}

byte[]/File/CompletableFuture<T>/RipResponse<T> are checked before any CallAdapterFactory at dispatch time — a factory "claiming" one passes validation but is silently never consulted. Only claim a return type RIP has no built-in support for.

❌ Mistake dispatching a second HTTP call from inside adapt()
public Optional<Object> adapt(CompletableFuture<Object> delegate) {
    return Optional.ofNullable(Unirest.get(someUrl).asString().getBody());   // never do this
}

Bypasses @Retry/cache/circuit-breaker/bulkhead/interceptors entirely for the second call. adapt must only ever transform delegate — never issue a new request.

Reactive — Mono<T> / Flux<T>

The rest-in-peace-reactor module adds a CallAdapterFactory for Project Reactor — add the dependency, call RestInPeaceReactor.register() once at startup, and any method can return Mono<T> or Flux<T> directly. Published on Maven Central alongside core. See the main README's Reactive (Project Reactor) section for the full walkthrough.

Basic · Mono<T> eager, not deferred — dispatched before anything subscribes
RestInPeaceReactor.register();   // once, at startup, before building any client

@GET("https://api.example.com/users/{id}")
Mono<User> getUser(@PathParam("id") String id);   // also: Mono<Void>, Mono<byte[]>, Mono<RipResponse<T>>
Alternate · Flux<T> from one response flavor 1 — decodes one JSON array, no real backpressure
@GET("https://api.example.com/orders")
Flux<Order> listOrders();
Alternate · Flux<T> over @Paginated flavor 2 — first page fetched eagerly at method call; later pages use real backpressure and are fetched only when demand exceeds what's buffered
@GET("https://api.example.com/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Flux<Order> streamOrders(@QueryParam("cursor") @PaginationCursor String cursor);

Pagination

@Paginated follows a next-page pointer automatically instead of leaving the fetch-extract-repeat loop to hand-written code — page.next() re-invokes the exact same annotated method through the full existing call pipeline (@Retry, cache, circuit breaker, bulkhead, interceptors), exactly as if it were called again by hand.

@Paginated + Page<T>

The pointer is extracted from the response body and resent through whichever parameter carries @PaginationCursor.

Basic
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Page listOrders(@QueryParam("cursor") @PaginationCursor String cursor);

Page page = orderApi.listOrders(null);
while (true) {
    for (Order order : page.items()) {
        process(order);
    }
    if (!page.hasNext()) {
        break;
    }
    page = page.next();
}
Alternate · page metadata status/headers for the page currently held, not wherever iteration has since moved to
RipResponse meta = page.rawResponse();
meta.getStatus();          // 200
meta.getHeader("ETag");
❌ Mistake Page<T> with no @Paginated and no PaginationStrategy<T>
@GET("/orders")
Page<Order> listOrders();   // missing @Paginated

Fails validation: "returns Page<T> but is not annotated with @Paginated and has no PaginationStrategy<T> parameter." Page<T> alone doesn't tell RIP how to find the next page.

Stream<T> / Iterator<T> auto-flattening

Same method, same annotation — just declare a different return type to flatten every page into one lazy sequence instead of managing Page<T> by hand. No network call happens until the first terminal operation / hasNext(), unlike Page<T>'s own eager first fetch.

Basic · Stream<T>
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Stream streamOrders(@QueryParam("cursor") @PaginationCursor String cursor);

orderApi.streamOrders(null).forEach(this::process);   // fetches pages on demand
long shipped = orderApi.streamOrders(null).filter(o -> "shipped".equals(o.status)).count();
Alternate · Iterator<T>
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Iterator iterateOrders(@QueryParam("cursor") @PaginationCursor String cursor);

Iterator it = orderApi.iterateOrders(null);
while (it.hasNext()) {
    process(it.next());
}
❌ Mistake @Paginated combined with @Url
@GET   // no @BaseUrl needed - a @Url parameter bypasses that check entirely
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Page<Order> listOrders(@Url String url, @QueryParam("cursor") @PaginationCursor String cursor);

Fails validation: "is annotated with both @Paginated and @Url - remove one or the other."

Termination signals

An API that keeps the pointer populated even on the genuinely last page (Stripe, for one) needs an explicit stop condition instead of "the pointer is gone". Precedence when more than one is set: hasMore → total count → total pages → pointer presence, always under an empty-page safety net.

Basic · hasMore boolean
@GET("/charges")
@Paginated(itemsField = "data", pointerField = "next_cursor",
        hasMoreSource = PaginationSignalSource.RESPONSE_BODY, hasMoreField = "has_more")
Page listCharges(@QueryParam("cursor") @PaginationCursor String cursor);
Alternate · total record count a running fetched-items counter compared against a header
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor",
        totalSource = PaginationSignalSource.RESPONSE_HEADER, totalField = "X-Total-Count")
Page listOrders(@QueryParam("cursor") @PaginationCursor String cursor);
Alternate · total page count
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor",
        totalPagesSource = PaginationSignalSource.RESPONSE_BODY, totalPagesField = "total_pages")
Page listOrders(@QueryParam("cursor") @PaginationCursor String cursor);
❌ Mistake a *Source set to ITEM_FIELD
@Paginated(itemsField = "orders", pointerField = "next_cursor",
        hasMoreSource = PaginationSignalSource.ITEM_FIELD)

Fails validation: "sets hasMoreSource = ITEM_FIELD, which is only meaningful for pointerSource." ITEM_FIELD only makes sense for where the cursor itself comes from, not a has-more/total signal.

POST body cursor

For a cursor resent as a JSON request body field (Elasticsearch's search_after, DynamoDB's ExclusiveStartKey) rather than a query/path/header value — stack @PaginationCursor on a @Body Map<String,Object> parameter, with bodyField naming the dotted-path field to overwrite.

Basic
@POST("/orders/search")
@Paginated(itemsField = "orders", pointerField = "search_after")
Page searchOrders(@Body @PaginationCursor(bodyField = "search_after") Map body);

Map body = new HashMap<>();
body.put("filter", "region:eu");
body.put("size", 100);
Page page = orderApi.searchOrders(body);
// every field the caller put in `body` carries forward unchanged on every
// subsequent page; only `search_after` gets overwritten, `body` itself is never mutated
❌ Mistake empty bodyField
Page<Order> searchOrders(@Body @PaginationCursor Map<String, Object> body);   // bodyField not set

Fails validation: "has a @PaginationCursor stacked on @Body but bodyField is empty - set it to the JSON path inside the body to write the next-page value to."

Keyset pagination & composite cursors

For a since_id/keyset-style API whose cursor isn't in the response envelope at all but derived from the last item on the page (Stripe, classic Twitter) — set pointerSource = ITEM_FIELD. A comma-separated pointerField covers an N-way composite key for a stable sort under concurrent writes.

Basic · single key
@GET("/charges")
@Paginated(itemsField = "data", pointerSource = PaginationSignalSource.ITEM_FIELD, pointerField = "id")
Page listCharges(@QueryParam("starting_after") @PaginationCursor String startingAfter);
Alternate · composite (id, timestamp) key positionally matched to two @PaginationCursor parameters
@GET("/events")
@Paginated(itemsField = "events", pointerSource = PaginationSignalSource.ITEM_FIELD, pointerField = "id,createdAt")
Page listEvents(@QueryParam("lastId") @PaginationCursor String lastId,
        @QueryParam("lastTs") @PaginationCursor String lastTimestamp);
❌ Mistake cursor-parameter count doesn't match pointerField's entries
@Paginated(itemsField = "events", pointerSource = PaginationSignalSource.ITEM_FIELD,
        pointerField = "id,createdAt")   // 2 entries
Page<Event> listEvents(@QueryParam("lastId") @PaginationCursor String lastId);   // only 1 @PaginationCursor

Fails validation: "needs 2 @PaginationCursor parameter(s) (matching pointerField's 2 comma-separated entries) - found 1."

Client-driven advance (no server pointer at all)

A homegrown endpoint that just expects the client to compute the next offset or page number itself, with no pointer to extract. INCREMENT_BY_PAGE_SIZE advances the offset by pageSize each fetch (stopping on a short page absent another signal); INCREMENT_BY_ONE advances the page number by one (stopping on an empty page).

Basic · offset + page size
@GET("/orders")
@Paginated(itemsField = "orders", pointerSource = PaginationSignalSource.NONE,
        advance = PaginationAdvance.INCREMENT_BY_PAGE_SIZE, pageSize = 50,
        totalSource = PaginationSignalSource.RESPONSE_HEADER, totalField = "X-Total-Count")
Page listOrders(@QueryParam("offset") @PaginationCursor int offset);
Alternate · page number
@GET("/orders")
@Paginated(itemsField = "orders", pointerSource = PaginationSignalSource.NONE,
        advance = PaginationAdvance.INCREMENT_BY_ONE,
        totalPagesSource = PaginationSignalSource.RESPONSE_BODY, totalPagesField = "total_pages")
Page listOrdersByPage(@QueryParam("page") @PaginationCursor int page);
❌ Mistake advance() set without pointerSource = NONE
@Paginated(itemsField = "orders", pointerField = "next_cursor",
        advance = PaginationAdvance.INCREMENT_BY_ONE)   // pointerSource defaults to RESPONSE_BODY, not NONE

Fails validation: "sets advance() but pointerSource is not NONE - advance() is only meaningful for client-driven pagination with no server-given pointer."

PaginationStrategy<T> — the programmatic escape hatch

For whatever the closed @Paginated attribute vocabulary can't reach — a cursor needing decoding before reuse, termination logic combining several signals with OR instead of the fixed precedence, a page count needing a separate API call. A plain parameter, no annotation, recognized by its declared type — the same idiom RIP already uses for CompletableFuture<T>/RipResponse<T> return types. Mutually exclusive with @Paginated on one method, and unlike it, the response body must itself be the JSON items array — there's no itemsField equivalent.

Basic · query param override
@GET("/orders")
Page listOrders(@QueryParam("status") String status, PaginationStrategy strategy);

PaginationStrategy strategy = ctx -> ctx.items().isEmpty() ? Optional.empty()
        : Optional.of(PaginationRequest.withQueryParam("since",
                ctx.items().get(ctx.items().size() - 1).getId()));

Page page = api.listOrders("active", strategy);
Alternate · follow a full URL
PaginationStrategy strategy = ctx -> {
    String next = ctx.header("X-Next-Page-Url");
    return next == null ? Optional.empty() : Optional.of(PaginationRequest.toUrl(next));
};
Alternate · combine two overrides a two-column composite keyset written into a @Body carrier
PaginationStrategy strategy = ctx -> {
    if (ctx.items().isEmpty()) {
        return Optional.empty();
    }
    Event last = ctx.items().get(ctx.items().size() - 1);
    return Optional.of(PaginationRequest.withBodyField("lastId", last.getId())
            .and(PaginationRequest.withBodyField("lastTimestamp", last.getCreatedAt())));
};
❌ Mistake @Paginated AND a PaginationStrategy<T> on one method
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Page<Order> listOrders(@QueryParam("cursor") @PaginationCursor String cursor,
        PaginationStrategy<Order> strategy);

Fails validation: "is annotated with @Paginated and also has a PaginationStrategy<T> parameter - use one or the other." Declarative and programmatic pagination are mutually exclusive.

Error handling & resilience

A non-2xx response always throws; retries and timeouts are declared per method.

RestInPeaceHttpException / @ErrorType

Basic · raw error body
try {
    User user = userApi.getUser("42");
} catch (RestInPeaceHttpException e) {
    e.getStatus();     // 404
    e.getRawBody();    // always available
}
Alternate · typed error body
@GET("/users/{id}")
@ErrorType(ApiError.class)
User getUser(@PathParam("id") String id);

catch (RestInPeaceHttpException e) {
    ApiError error = e.getErrorBody();   // deserialized, not raw
    System.out.println(error.code);
}
Alternate · status-range helpers
catch (RestInPeaceHttpException e) {
    if (e.isServerError()) { scheduleRetryLater(); }        // 500-599
    else if (e.isClientError()) { throw new IllegalArgumentException(); }  // 400-499
}
Pitfall · order is(status) before the range checks a specific-status branch after isClientError()/isServerError() is unreachable - that status is already inside the range
// Wrong - e.is(429) never runs
if (e.isClientError()) { ... }
else if (e.is(429)) { ... }

// Right - check the specific case first
if (e.is(429)) { ... }
else if (e.isClientError()) { ... }
Alternate · honor the server's own Retry-After same parsing @Retry uses internally, for a method with no @Retry at all
catch (RestInPeaceHttpException e) {
    if (e.is(429) && e.getRetryAfterMillis() != null) {
        Thread.sleep(e.getRetryAfterMillis());
        return retryManually();
    }
}

@Retry & idempotency keys

Retries on transport errors or a configurable status set; honors a response's own Retry-After header when present; idempotent=true makes retrying a POST safe too.

Basic default retryOnStatus: 429, 502, 503, 504
@GET("https://api.example.com/users/{id}")
@Retry(times = 3, delayMillis = 200, backoffMultiplier = 2.0)
User getUser(@PathParam("id") String id);
Alternate · fixed delay
@Retry(times = 3, delayMillis = 500, backoffMultiplier = 1.0)   // no exponential backoff
Alternate · jittered backoff avoids many replicas retrying in lockstep; overridden per-attempt by a Retry-After header when the response has one
@Retry(times = 3, delayMillis = 200, backoffMultiplier = 2.0, jitterFactor = 0.2)
Alternate · safe retry for POST one Idempotency-Key header, identical across attempts
@POST("https://api.example.com/charges")
@Retry(times = 3, delayMillis = 200, idempotent = true)
String createCharge(@Body Charge charge);
Alternate · interface-level default like @BaseUrl — a method's own @Retry still wins over this
@RestClient
@Retry(times = 3, delayMillis = 200, retryOnStatus = { 503 })
public interface UserApi {
    @GET("/users/{id}")
    User getUser(@PathParam("id") String id);   // uses the interface's @Retry
}
Alternate · client-wide default lowest priority: method @Retry > interface @Retry > RetryConfig
RIP.getClient(UserApi.class, RipClientConfig.builder()
        .retry(RetryConfig.builder().times(3).delayMillis(200).retryOnStatus(503).build())
        .build());
Alternate · retry budget caps TOTAL retries across the client, not one call's own times()
RIP.getClient(UserApi.class, RipClientConfig.builder()
        .retryBudget(50, TimeUnit.MINUTES.toMillis(1))   // at most 50 retries/minute, client-wide
        .build());
// A token bucket, not a once-a-minute burst - refills continuously.
// Once exhausted, a call that would retry instead gives up immediately,
// exactly like reaching its own @Retry times().

@Timeout

Overrides connect/read timeout for one method only. Priority: method @Timeout > interface-level @Timeout > RipClientConfig > shared client default.

Basic
@GET("https://api.example.com/reports/export")
@Timeout(readMillis = 120_000)
String exportReport();
Alternate · interface-level default
@RestClient
@Timeout(readMillis = 50)
public interface SlowApi {
    @GET("/slow/{id}")
    String getSlow(@PathParam("id") String id);   // uses the interface's @Timeout
}
❌ Mistake negative value other than the -1 sentinel
@Timeout(readMillis = -5000)

Fails validation: "readMillis must be -1 (unset) or a non-negative number of milliseconds." Use -1 (or just omit the attribute) to mean "unset" — never another negative number.

Circuit breaker

Once a client's failure rate crosses a threshold, every call fails fast with CircuitOpenException for a cooldown period instead of paying for a timeout/@Retry loop that would fail anyway.

Basic count-based sliding window, the last N calls
RIP.getClient(UserApi.class, RipClientConfig.builder()
        .circuitBreaker(CircuitBreakerConfig.builder()
                .slidingWindowSize(20)             // the last 20 calls
                .minimumNumberOfCalls(10)           // don't evaluate a rate below this
                .failureRateThreshold(50)            // trip at 50% failures
                .waitDurationInOpenState(Duration.ofSeconds(30))
                .permittedCallsInHalfOpenState(3)
                .build())
        .build());
Alternate · time-based window "last 30 seconds" instead of "last N calls"
CircuitBreakerConfig.builder().slidingWindowSize(Duration.ofSeconds(30))...
Alternate · delegate to resilience4j CircuitBreakerProvider — zero hard dependency either way
CircuitBreaker r4jBreaker = CircuitBreaker.ofDefaults("payment-api");

RipClientConfig config = RipClientConfig.builder().circuitBreaker(new CircuitBreakerProvider() {
    public boolean tryAcquirePermission() { return r4jBreaker.tryAcquirePermission(); }
    public void onSuccess(long durationNanos, int statusCode) {
        if (statusCode >= 500) r4jBreaker.onError(durationNanos, TimeUnit.NANOSECONDS, new RuntimeException("HTTP " + statusCode));
        else r4jBreaker.onSuccess(durationNanos, TimeUnit.NANOSECONDS);
    }
    public void onError(long durationNanos, Throwable t) { r4jBreaker.onError(durationNanos, TimeUnit.NANOSECONDS, t); }
}).build();
UserApi api = RIP.getClient(UserApi.class, config);
❌ Mistake minimumNumberOfCalls bigger than the window
CircuitBreakerConfig.builder()
        .slidingWindowSize(5)
        .minimumNumberOfCalls(10)   // can never be reached by a window of 5
        .build();

Not rejected by validation — both values are independently valid — but the breaker can never evaluate a failure rate at all, since the window never holds enough calls to reach minimumNumberOfCalls. Keep it ≤ slidingWindowSize.

❌ Mistake out-of-range builder values
CircuitBreakerConfig.builder().failureRateThreshold(0).build();
CircuitBreakerConfig.builder().failureRateThreshold(101).build();

Both throw IllegalArgumentException: "failureRateThreshold must be between 1 and 100 inclusive." — same pattern for slidingWindowSize(0), minimumNumberOfCalls(0), waitDurationInOpenState(Duration.ZERO), and permittedCallsInHalfOpenState(0), each with its own matching message.

Bulkhead

Caps how many calls to a client can be in flight at once, regardless of success/failure — so one slow downstream can't starve every other call sharing the same capacity.

Basic fails fast by default (resilience4j's own shape)
RIP.getClient(UserApi.class, RipClientConfig.builder()
        .bulkhead(BulkheadConfig.builder()
                .maxConcurrentCalls(25)
                .maxWaitDuration(Duration.ofMillis(500))   // queue briefly instead of failing outright
                .build())
        .build());
❌ Mistake confusing BulkheadFullException with CircuitOpenException
try {
    api.getUser("42");
} catch (CircuitOpenException e) {
    // never reached for a full bulkhead — it throws BulkheadFullException,
    // a distinct type for a distinct reason (too much concurrent volume,
    // not a failure-rate trip). Catch both separately.
}
❌ Mistake invalid builder values
BulkheadConfig.builder().maxConcurrentCalls(0).build();
// IllegalArgumentException: "maxConcurrentCalls must be at least 1."
BulkheadConfig.builder().maxWaitDuration(Duration.ofMillis(-1)).build();
// IllegalArgumentException: "maxWaitDuration must not be negative."

Client configuration

Everything one client's environment can differ in — base URL, timeout, proxy, JSON mapper, cache, interceptors — lives on RipClientConfig.

RipClientConfig.builder()

Setting a timeout, proxy, or objectMapper gives the client its own connection pool instead of sharing the app-wide one.

Basic · full builder
UserApi prodApi = RIP.getClient(UserApi.class, RipClientConfig.builder()
        .baseUrl(prodBaseUrl)
        .connectTimeoutMillis(2_000)
        .readTimeoutMillis(10_000)
        .proxy("proxy.example.com", 8080)                 // or proxy(host, port, user, pass)
        .objectMapper(new JacksonObjectMapper(myMapper))
        .build());
Spring · YAML-bound no @Bean method — resolved per client by name
rest-in-peace:
  clients:
    user-api:                 # @RestClient(name = "user-api"), or the
                               # decapitalized interface name by default
      connect-timeout-millis: 2000
      read-timeout-millis: 10000
      proxy: { host: proxy.example.com, port: 8080 }

JSON ObjectMapper

Gson-backed by default. Swap it app-wide, or per client.

Basic · shared default every client sharing the static Unirest client
RIP.setObjectMapper(new JacksonObjectMapper(myJacksonMapper));   // once, at startup
Alternate · one client's own RIP.setObjectMapper has no effect on this one
RipClientConfig.builder().objectMapper(new JacksonObjectMapper(myMapper)).build();
Spring · by bean, qualified or shared
@Bean
@Qualifier("user-api")           // wired only into the user-api client
ObjectMapper userApiMapper() { return new JacksonObjectMapper(myMapper); }

@Bean                            // unqualified — used as the shared default
ObjectMapper sharedMapper() { return new JacksonObjectMapper(myMapper); }
// ^ only applies when it's the single unqualified bean of that type

Response caching

Honors Cache-Control/ETag/Last-Modified — never invents caching the server didn't ask for. GET/POJO only, for now.

Cache — shared, per-client, or a bean

Basic · shared default
RIP.setCache(new InMemoryCache());   // ConcurrentHashMap-backed, ships free
Alternate · one client's own
UserApi api = RIP.getClient(UserApi.class,
        RipClientConfig.builder().cache(new InMemoryCache()).build());

// Or back it with Redis / Caffeine / anything else:
class RedisCache implements Cache {
    public CachedResponse get(String key) { ... }
    public void put(String key, CachedResponse r) { ... }
    public void evict(String key) { ... }
    public void clear() { ... }
}
Alternate · opt one method out
@GET("https://api.example.com/prices/{symbol}")
@NoCache
Price getLivePrice(@PathParam("symbol") String symbol);
Spring · qualified or shared bean
@Bean
@Qualifier("user-api")
Cache userApiCache() { return new InMemoryCache(); }

Stale-while-revalidate

A stale entry normally blocks on a synchronous revalidation round trip. stale-while-revalidate=N serves it as-is instead, refreshing in the background for the next call.

Server response header
// Cache-Control: max-age=60, stale-while-revalidate=30
Item item = api.getItem("42");
// fresh for 60s, then still instantly servable (stale) for 30s more
// while a background call refreshes the entry - this call never sees
// the new response, only ever the one already in hand
Pitfall · a failed background refresh is silent the stale entry just keeps serving until it ages out of its own window too
// A background refresh throwing, or answering with a non-2xx/non-304
// response carrying no caching headers, is swallowed - nothing is
// logged, nothing is thrown at the caller who triggered it. Once the
// stale-while-revalidate window itself elapses, the next call falls
// back to the usual synchronous revalidation (or re-fetch).

Negative caching

Every other cached status is stored because the server said so. A confirmed 404 is different - negativeCacheTtlMillis opts in regardless of the response's own headers.

Basic · per client
UserApi api = RIP.getClient(UserApi.class, RipClientConfig.builder()
        .cache(new InMemoryCache())
        .negativeCacheTtlMillis(TimeUnit.MINUTES.toMillis(1))
        .build());

api.getUser("does-not-exist");   // throws RestInPeaceHttpException(404, ...) - one real network call
api.getUser("does-not-exist");   // throws the same exception again - served from cache
Alternate · shared default a client's own setting still wins over this
RIP.setNegativeCacheTtlMillis(TimeUnit.MINUTES.toMillis(1));
❌ Mistake expecting it to cover every error status
api.getUser("rate-limited");   // a 429, NOT a 404 - never negatively cached

Only ever applies to a confirmed 404 — a 429/500/anything else is never cached under this mechanism, since those are transient failures, not a confirmed "this doesn't exist."

Time-based & manual eviction

Separate from server-driven freshness: how long an entry is held at all, and forcing one out on demand.

Basic · max-age eviction checked lazily on the next get() for that key, no background thread
RIP.setCache(new InMemoryCache(TimeUnit.MINUTES.toMillis(10)));
Alternate · manual eviction Cache.key(...) computes the same key CacheCoordinator uses internally
cache.evict(Cache.key(HTTPMethod.GET, "https://api.example.com/users/42"));
❌ Mistake eviction key doesn't match the cached one exactly
cache.evict(Cache.key(HTTPMethod.GET, "https://api.example.com/users/42/"));  // trailing slash

Cache.key(...) does no normalization — a URL that doesn't match the exact one actually requested (trailing slash, casing, query string) silently misses, and the stale entry stays cached with no error.

Query string in the cache key

On by default — /items?page=1 and ?page=2 cache separately. Turn it off for a query param that doesn't affect the response.

Basic · per client
RIP.getClient(UserApi.class, RipClientConfig.builder()
        .cache(new InMemoryCache())
        .cacheKeyIncludesQueryString(false)
        .build());
Alternate · shared default a client's own setting still wins over this
RIP.setCacheKeyIncludesQueryString(false);
❌ Mistake the sharpest footgun in caching
// cacheKeyIncludesQueryString(false) on a client whose query params
// DO change the response:
api.search("laptops");   // cached under just the path
api.search("phones");    // served the CACHED "laptops" response instead
                          // of a fresh search - no error, looks like a hit

Only disable this when you've confirmed every cached endpoint's query params are provably irrelevant to the response (pure tracking params the server ignores) — otherwise it silently serves wrong data.

Interceptors

"Onion" ordering: global beforeRequest runs first, global afterResponse runs last — global interceptors bracket every per-client one.

Global vs. per-client

Basic · global, every client
RIP.addInterceptor(new RequestInterceptor() {
    public void beforeRequest(RequestContext ctx) {
        ctx.addHeader("Authorization", "Bearer " + currentToken());
    }
    public void afterResponse(RequestContext ctx, int status, Object body) {
        System.out.println(ctx.getHttpMethod() + " " + ctx.getUrl() + " -> " + status);
    }
});

RIP.clearInterceptors();   // removes everything registered
Alternate · inspect the outgoing body raw string for @Body String, serialized JSON for a POJO @Body; null if no @Body
public void beforeRequest(RequestContext ctx) {
    String body = ctx.getBody();   // read-only — setting it here has no effect
}
Alternate · short-circuit the network call feature-flag bypass, canary short-circuit, or record/replay - first non-null wins, FIFO
public ShortCircuitResponse shortCircuit(RequestContext ctx) {
    if (featureFlags.isEnabled("bypass-pricing-api")) {
        return ShortCircuitResponse.ok("{\"price\":0}");
    }
    return null;   // let the call proceed normally
}
❌ Mistake expecting setBody() to change what's sent
public void beforeRequest(RequestContext ctx) {
    ctx.setBody(redact(ctx.getBody()));   // has NO effect on the wire
}

The actual request is already built by the time interceptors run — setBody(...) only updates what RequestContext itself reports to a later interceptor/your own code, never the bytes Unirest sends.

❌ Mistake null doesn't mean "short-circuit with nothing"
public ShortCircuitResponse shortCircuit(RequestContext ctx) {
    return null;   // means "make the real call" - NOT "short-circuit with an empty response"
}

Use ShortCircuitResponse.status(204, "") (or whatever you actually want) to short-circuit with an intentionally empty response — null always means "let the real call proceed."

Alternate · reproduce a call with curl CurlVerbosity: NONE, VERBOSE (-v), VV (-vv), VVV (-vvv), VVVV (-vvvv), or TRACE (--trace-ascii - --trace-time)
public void afterResponse(RequestContext ctx, int status, Object body) {
    if (status >= 400) {
        System.err.println("Reproduce with:\n" + ctx.toCurlCommand());
    }
}
Pitfall · VV/VVV/VVVV are undocumented curl behavior repeating -v scales verbosity (verified on curl 8.22.0) but isn't in curl's own --help/man page - prefer TRACE when stability matters
ctx.toCurlCommand(RequestContext.CurlVerbosity.VVVV);   // curl's own internal DNS/TCP/engine tracing - noisy, undocumented
ctx.toCurlCommand(RequestContext.CurlVerbosity.TRACE);   // same ballpark of detail, but documented + stable
Alternate · scoped to one client
StripeApi stripe = RIP.getClient(StripeApi.class, RipClientConfig.builder()
        .baseUrl("https://api.stripe.com")
        .interceptors(List.of(new HeaderInterceptor("Authorization", () -> "Bearer " + stripeKey)))
        .build());
Spring · qualifier decides scope unqualified → global; qualified → that one client
@Bean                              // no @Qualifier — registered globally at startup
RequestInterceptor authInterceptor() {
    return new HeaderInterceptor("Authorization", () -> "Bearer " + currentToken());
}

@Bean
@Qualifier("stripe-api")           // wired only into the stripe-api client's own config
RequestInterceptor stripeAuth() { ... }

Pre-built interceptors

Registered exactly like a hand-written one — plain Java shown; each also works as a @Bean in Spring, qualified or not.

HeaderInterceptor fixed, Supplier, or a map of either
RIP.addInterceptor(new HeaderInterceptor("Authorization", "Bearer " + token));      // fixed value
RIP.addInterceptor(new HeaderInterceptor("Authorization", () -> currentToken()));    // re-evaluated per call
RIP.addInterceptor(HeaderInterceptor.of(Map.of("X-Api-Key", "abc123")));             // several at once
RIP.addInterceptor(new HeaderInterceptor(Map.of("Authorization", () -> currentToken())));
LoggingInterceptor
RIP.addInterceptor(new LoggingInterceptor());                 // to System.out
RIP.addInterceptor(new LoggingInterceptor(logger::info));     // or your own sink
RedactingLoggingInterceptor also logs bodies, masking configured field names
RIP.addInterceptor(new RedactingLoggingInterceptor());   // masks password/token/secret/apiKey/ssn/authorization
RIP.addInterceptor(new RedactingLoggingInterceptor(Set.of("password", "creditCardNumber"), logger::info));
Pitfall · a regex mask, not a JSON parser reliable for a flat field; best-effort once its value is a nested object/array
// "password": "hunter2"           -> "password": "***"          (masked cleanly)
// "token": { "value": "x" }       -> masked only up to the value's first , } or ]
CorrelationIdInterceptor
RIP.addInterceptor(new CorrelationIdInterceptor());                                // random UUID, X-Request-Id
RIP.addInterceptor(new CorrelationIdInterceptor("X-Trace-Id", () -> traceIdGenerator.next()));
MetricsInterceptor one sample per attempt, incl. retries
RIP.addInterceptor(new MetricsInterceptor((method, url, status, ms) ->
        System.out.printf("%s %s -> %d (%dms)%n", method, url, status, ms)));
❌ Mistake the fixed-value constructor for a token that expires
RIP.addInterceptor(new HeaderInterceptor("Authorization", "Bearer " + currentToken()));
// currentToken() is called ONCE, at registration time - every later call
// sends that same original token, even long after it's expired.

Use the Supplier<String> constructor whenever the value can change between calls — it's re-evaluated on every request.

Compile-time proxies & testing

Two things that happen underneath the interface, without changing how you call it.

Compile-time proxy generation

No opt-in — a supported method shape gets a real generated <Interface>_RipImpl class automatically; an unsupported method on the same interface falls back internally, method by method, not the whole interface.

Zero code change same call site either way
UserApi api = RIP.getClient(UserApi.class);
// api.getClass() is UserApi_RipImpl        — supported shape, reflection-free
// api.getClass() is a java.lang.reflect.Proxy — NO method on this interface is codegen-eligible
Mixed interface List<User> isn't Class<?>-decodable — same limitation on both dispatch paths
interface UserApi {
    User getUser(String id);        // codegen-eligible — real generated method
    List<User> listUsers();        // not eligible — delegates internally to a lazily-built reflective proxy
}
// api.getClass() is still UserApi_RipImpl — one unsupported method no longer disqualifies the rest
❌ Mistake depending on the generated class directly
UserApi_RipImpl impl = new UserApi_RipImpl();   // don't do this

The generated class's name/shape isn't committed public API and can change between releases. Always go through RIP.getClient(UserApi.class).

OpenAPI → @RestClient generator

The opposite direction - reads an OpenAPI 3.x JSON document and generates the interface itself, instead of hand-writing one.

Basic
OpenApiClientGenerator.generate(
        new File("petstore-openapi.json"),   // spec - JSON, not YAML
        new File("src/main/java"),
        "com.example.client",
        "PetStoreApi");
// writes src/main/java/com/example/client/PetStoreApi.java
Pitfall · a skeleton generator, not full schema-to-POJO every parameter and body is String - narrow types / swap in your own POJO by hand afterward
// servers[0].url -> @BaseUrl; OpenAPI's own {name} path placeholder already
// matches @PathParam's, so paths need no translation at all. header/cookie
// parameters are skipped (not part of the interface shape); any requestBody
// becomes one @Body String parameter regardless of its declared schema.
Also runnable from the CLI
java -cp ... com.shri.restinpeace.codegen.OpenApiClientGenerator \
    petstore-openapi.json src/main/java com.example.client PetStoreApi
❌ Mistake pointing it at a YAML spec
OpenApiClientGenerator.generate(new File("openapi.yaml"), outDir, pkg, name);

Only JSON specs are supported — convert a YAML OpenAPI document to JSON first (most OpenAPI tooling can export either format).

MockRestServer

A real local HTTP server — @Retry, @Timeout, and every interceptor run completely unmodified against it.

Basic · manual lifecycle
MockRestServer server = MockRestServer.start();
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42", "shipped")));

OrderApi api = RIP.getClient(OrderApi.class, server.baseUrl());
assertEquals("shipped", api.getOrder("42").status);
assertEquals(1, server.countOf(HTTPMethod.GET, "/orders/{id}"));
server.close();
Alternate · JUnit 5 extension no Spring context needed at all
@ExtendWith(MockRestServerExtension.class)
class OrderApiTest {
    @Test
    void getOrder_returnsDecodedBody(MockRestServer server) {
        server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42", "shipped")));
        OrderApi api = RIP.getClient(OrderApi.class, server.baseUrl());
        assertEquals("shipped", api.getOrder("42").status);
    }
}
Alternate · prove @Retry recovers
server.onFlaky(HTTPMethod.GET, "/orders/{id}", 2,
        MockResponse.status(503, ""), MockResponse.json(new Order("42", "shipped")));

Order order = orderApiWithRetry.getOrder("42");   // succeeds on the 3rd attempt
Alternate · script a @Paginated/PaginationStrategy<T> page sequence answers by request order, not by matching each page's differing cursor/offset query param - no per-cursor route needed
server.onPages(HTTPMethod.GET, "/orders",
        MockResponse.ok("{\"orders\":[{\"id\":\"1\"}],\"has_more\":true,\"next_cursor\":\"tok\"}"),
        MockResponse.ok("{\"orders\":[{\"id\":\"2\"}],\"has_more\":false}"));

Page page1 = api.listOrders(null);   // page1.items() -> [{"id": "1"}]
Page page2 = page1.next();           // page2.items() -> [{"id": "2"}]
// a 3rd call would replay the last response (has_more: false) rather than error
Alternate · simulate what @Retry/@Timeout actually handle connectionFailure() aborts the socket - a transport error, not an HTTP status - the same failure shape a real dropped connection produces
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.connectionFailure());
// @Retry sees this exactly like a real network failure, no status code involved

server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.ok("{}").delay(300));
// proves a 200ms @Timeout(readMillis = 200) actually fires

server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.notModified());
// 304 shorthand for an ETag/If-None-Match revalidation round trip
Alternate · auto-report unhit routes requires the static @RegisterExtension field style - @ExtendWith can't call reportUnhitRoutes() first
class OrderApiTest {
    @RegisterExtension
    static MockRestServerExtension extension = new MockRestServerExtension().reportUnhitRoutes();

    @Test
    void getOrder_returnsDecodedBody(MockRestServer server) {
        server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42", "shipped")));
        // a route left registered but never called prints a warning at afterAll
    }
}
Alternate · reset() between tests clears registered routes, the enqueue() queue, and every recorded request - a clean server without restarting the HTTP listener
@BeforeEach
void resetServer() {
    server.reset();   // one shared server across a whole test class, no cross-test leakage
}
❌ Mistake enqueue() when a route already covers the path
server.on(HTTPMethod.GET, "/orders", MockResponse.json(allOrders));
server.enqueue(MockResponse.status(503, ""));   // DEAD - on(...) already claims every GET /orders

// RIGHT tool: enqueueFor targets the already-registered route directly.
server.enqueueFor(HTTPMethod.GET, "/orders", MockResponse.status(503, ""));

enqueue(...) only ever answers a path with no route registered via on(...) at all — route matching always happens first and unconditionally shadows the plain queue for a path that has one.

❌ Mistake forgetting server.close()
@Test
void getOrder_works() {
    MockRestServer server = MockRestServer.start();
    // ... no server.close() anywhere - leaks a real bound socket
}

Use MockRestServerExtension or a @BeforeEach/@AfterEach pair so close() always runs, even if the test body throws.

Unmatched request · closest-route suggestion same HTTP method, different path registered - usually a typo in the test setup
server.on(HTTPMethod.POST, "/order", MockResponse.ok("{}"));

api.createOrder("{...}");   // actually POSTs to /orders

// RestInPeaceHttpException.getRawBody():
// MockRestServer: no response was queued or registered for POST /orders.
// Did you mean: POST /order?
Alternate · match by header/body content an extra Predicate<RecordedRequest> condition, on top of method + path - re-registering never replaces an earlier matcher route; remove() first if that's the intent
server.on(HTTPMethod.GET, "/orders/{id}",
        request -> "v2".equals(request.getHeader("Accept-Version")),
        MockResponse.json(new OrderV2("42")));
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42")));
// ^ plain fallback route for every request that doesn't match the predicate above
Alternate · assert on what a @Multipart/@FormUrlEncoded request actually sent getBody() can't represent a multipart request's binary parts - getParts()/getFormFields() decode what RIP actually built on the wire
RecordedRequest sent = server.takeRequest();

RecordedRequest.Part file = sent.getParts().get(0);
file.getName();               // "file"
file.getFileName();           // "avatar.png"
file.getContentType();        // "image/png"
file.getContent();            // raw bytes
file.getContentAsString();    // decoded as text - for a text part, not a binary one

sent.getFormFields().get("grant_type");   // List.of("client_credentials") - @FormUrlEncoded instead
Spring · @AutoConfigureMockRestServer redirects every registered client's base URL for you
@SpringBootTest
@AutoConfigureMockRestServer
class UserServiceTest {
    @Autowired MockRestServer server;
    @Autowired UserApi userApi;   // already pointed at server.baseUrl()

    @Test
    void getUser_returnsDecodedBody() {
        server.on(HTTPMethod.GET, "/users/{id}", MockResponse.json(new User("42", "Shrinivas")));
        assertEquals("Shrinivas", userApi.getUser("42").name);
    }
}

Java, Spring, Kotlin

The construction site is the one place plain Java and Spring genuinely diverge — everything above is identical once you have a client instance.

Plain Java, CLI tools, scripts

Basic · construct once, reuse
public final class Clients {
    public static final UserApi USER_API = RIP.getClient(UserApi.class);
    private Clients() {}
}

Spring — two ways to wire a bean

Spring Boot starter 4.x, Java 17+ — zero hand-written @Bean method
@SpringBootApplication
@EnableRestInPeaceClients
class Application { }

@RestClient(baseUrlProperty = "user-api.base-url")
interface UserApi {
    @GET("/users/{id}")
    User getUser(@PathParam("id") String id);
}
Alternate · hand-written @Bean Spring on older Java, or anywhere you'd rather not scan
@Configuration
class RipClientsConfig {
    @Bean
    UserApi userApi(@Value("${user-api.base-url}") String baseUrl) {
        return RIP.getClient(UserApi.class, baseUrl);
    }

    @Bean
    StripeApi stripeApi(@Value("${stripe.api-key}") String apiKey) {
        return RIP.getClient(StripeApi.class, RipClientConfig.builder()
                .baseUrl("https://api.stripe.com")
                .connectTimeoutMillis(2_000)
                .interceptors(List.of(new HeaderInterceptor("Authorization", () -> "Bearer " + apiKey)))
                .build());
    }
}
❌ Mistake @RestClient interfaces exist but nothing enables scanning
@SpringBootApplication
class Application { }   // no @EnableRestInPeaceClients anywhere

No beans are registered at all — injecting UserApi anywhere fails with Spring's own NoSuchBeanDefinitionException. The starter being on the classpath doesn't trigger scanning by itself.

❌ Mistake baseUrlProperty names a property that was never set
@RestClient(baseUrlProperty = "user-api.base-url")
interface UserApi { ... }
// application.yml never sets user-api.base-url

Fails Spring Boot startup entirely: IllegalStateException: Required key 'user-api.base-url' not found — the whole application context fails to refresh, not just this one bean.

Kotlin, Scala, other JVM languages

No Java-specific runtime magic beyond an annotated interface — works from any JVM language that can implement one.

Basic · Kotlin
@RestClient
@BaseUrl("https://api.example.com")
interface UserApi {
    @GET("/users/{id}")
    fun getUser(@PathParam("id") id: String): User
}
This page is generated documentation, kept alongside README.md and docs/design/spring-boot-starter.md in the repository. Amber = plain Java · core library. Green = rest-in-peace-spring-boot-starter. Full API surface: Javadoc.