Riptide HTTP Client tutorial
Riptide: learning the fundamentals of the open source Zalando HTTP client

Overview
Riptide is a Zalando open source Java HTTP client that implements declarative client-side response routing. It allows dispatching HTTP responses very easily to different handler methods based on various characteristics of the response, including status code, status family, and content type. The way this works is similar to server-side request routing, where any request that reaches a web application is usually routed to the correct handler based on the combination of URI (including query and path parameters), method, Accept and Content-Type header. With Riptide, you can define handler methods on the client side based on the response characteristics. See the concept document for more details. Riptide is part of the core Java/Kotlin stack and is used in production by hundreds of applications at Zalando.
In this tutorial, we'll explore the fundamentals of Riptide HTTP client. We'll learn how to initialize it and examine various use cases: sending simple GET and POST requests, and processing different responses.
Maven Dependencies
First, we need to add the library as a dependency into the pom.xml file:
<dependency>
<groupId>org.zalando</groupId>
<artifactId>riptide-core</artifactId>
<version>${riptide.version}</version>
</dependency>
Check Maven Central page to see the latest version of the library.
Client Initialization
To send HTTP requests, we need to build an Http object, then we can use it for all our HTTP requests for the specified base URL:
Http.builder()
.executor(executor)
.requestFactory(new SimpleClientHttpRequestFactory())
.baseUrl(getBaseUrl(server))
.build();
Sending Requests
Sending requests using Riptide is pretty straightforward: you need to use an appropriate method from the created Http object depending on the HTTP request method. Additionally, you can provide a request body, query params, content type, and request headers.
GET Request
Here is an example of sending a simple GET request:
http.get("/products")
.header("X-Foo", "bar")
.call(pass())
.join();
POST Request
POST requests also can be sent easily:
http.post("/products")
.header("X-Foo", "bar")
.contentType(MediaType.APPLICATION_JSON)
.body("str_1")
.call(pass())
.join();
In the next sections, we will explain the meanings of the call, pass, and join methods from the code snippets above.
Response Routing
One of the main features of the Riptide HTTP client is declarative response routing. We can use the dispatch method to specify processing logic (routes) for different response types. The dispatch method accepts the Navigator object as its first parameter, this parameter specifies which response attribute will be used for the routing logic.
Riptide has several default Navigator-s:
| Navigator | Response characteristic |
|---|---|
Navigators.series() | Class of status code |
Navigators.status() | Status |
Navigators.statusCode() | Status code |
Navigators.reasonPhrase() | Reason Phrase |
Navigators.contentType() | Content-Type header |
Simple Routing
Let's see how we can use response routing:
http.get("/products/{id}", 100)
.dispatch(status(),
on(OK).call(Product.class, product -> log.info("Product: " + product)),
on(NOT_FOUND).call(response -> log.warn("Product not found")),
anyStatus().call(pass()))
.join();
In this example, we demonstrate retrieving a product by its ID and handling the responses. We use the Navigators.status() static method to route our responses based on their statuses. We then describe processing logic for different statuses:
OK- we use a version of thecallmethod that deserializes the response body into the specified type (Productin our case). This deserialized object is then used as a parameter for a consumer, which is passed as a second argument to thecallmethod. In our example, the consumer simply logs theProductobject.NOT_FOUND- we assume that we won't receive aProductresponse, so we use another version of thecallmethod with a single argument: a consumer acceptingorg.springframework.http.client.ClientHttpResponse. In this scenario, we decide to log a warning message.- All other statuses we intend to process in the same way. To achieve this we use the
Bindings.anyStatus()static function, allowing us to describe the processing logic for all remaining statuses. In our case, we have decided that no action is required for such statuses, so we utilize thePassRoute.pass()static method, that returns do-nothing handler.
In Riptide all requests are sent using an Executor (configured in the executor method in the Client initialization section). Because of this, responses are always processed in separate threads and the dispatch method returns CompletableFuture<ClientHttpResponse>. To make the invoking thread waiting for the response to be processed, we use the join() method in our example.
Nested Routing
We can have nested (multi-level) routing for our responses. For example, the first level of routing can be based on the response series, and the second level - on specific status codes:
http.get("/products/{id}", 100)
.dispatch(series(),
on(SUCCESSFUL).call(Product.class, product -> log.info("Product: " + product)),
on(CLIENT_ERROR).dispatch(
status(),
on(NOT_FOUND).call(response -> log.warn("Product not found")),
on(TOO_MANY_REQUESTS).call(response -> {throw new RuntimeException("Too many reservation requests");}),
anyStatus().call(pass())),
on(SERVER_ERROR).call(response -> {throw new RuntimeException("Server error");}),
anySeries().call(pass()))
.join();
In the example above, we implement nested routing. First, we dispatch our responses based on the series using the static method Navigators.series(), and then we dispatch CLIENT_ERROR responses based on their specific statuses. For other series such as SUCCESSFUL, we utilize a single handler per series without any nested routing.
Similar to the previous example, we use the PassRoute.pass() static method to skip actions for certain cases. Additionally, we use Bindings.anyStatus() and Bindings.anySeries() methods to define default behavior for all series or statuses that are not explicitly described. Furthermore, in this example, we've chosen to throw exceptions for specific cases, these exceptions can be then caught and processed in the invoking code - see TOO_MANY_REQUESTS status and SERVER_ERROR series routes.
Returning Response Objects
In some cases we need to return a response object from the REST endpoints invocation - we can use a riptide-capture module to do so.
Let's take a look on a simple example:
ClientHttpResponse clientHttpResponse = http.get("/products/{id}", 100)
.dispatch(status(),
on(OK).call(Product.class, product -> log.info("Product: {}", product)),
anyStatus().call(response -> {throw new RuntimeException("Invalid status");}))
.join();
As mentioned earlier, when we invoke the dispatch method, it returns a CompletableFuture<ClientHttpResponse>. If we then invoke the join() method and wait for the result of invocation - we'll get an object of type ClientHttpResponse. However, with the assistance of the riptide-capture module, we can return a deserialized object from the response body instead. In our example, the deserialized object has a type Product.
First, we need to add a dependency for the riptide-capture module:
<dependency>
<groupId>org.zalando</groupId>
<artifactId>riptide-capture</artifactId>
<version>${riptide.version}</version>
</dependency>
Now let's rewrite the previous example using the Capture class. This class allows us to extract a value of a specified type from the response body:
Capture<Product> capture = Capture.empty();
Product product = http.get("/products/{id}", 100)
.dispatch(status(),
on(OK).call(Product.class, capture),
anyStatus().call(response -> {throw new RuntimeException("Invalid status");}))
.thenApply(capture)
.join();
In this example, we pass the capture object to the route for the OK status. The purpose of the capture object is to deserialize the response body into a Product object and store it for future use. Then we invoke the thenApply(capture) method to retrieve stored Product object. The thenApply(capture) method will return a CompletableFuture<Product>, so we again can utilize the join() method to get a Product object, as we did in the previous examples. See also the riptide-capture module page for more details.
Conclusion
In this article, we've demonstrated the fundamental use cases of the Riptide HTTP client. You can find the code snippets with complete imports on GitHub.
In future articles, we'll explore usage of Riptide plugins - they provide additional logic for your REST client, such as retries, authorization, metrics publishing etc. Additionally, we'll look at Riptide Spring Boot starter, that simplifies an Http object initialization.
We're hiring! Do you like working in an ever evolving organization such as Zalando? Consider joining our teams as a Software Engineer!




