Production-Ready Java REST Client: Retries, Pagination, Rate Limits, and Error Handling
A simple REST client is easy to build.
A production-ready REST client is a different story.
Once your application starts talking to real APIs, you quickly run into problems such as:
- Temporary server failures
- Rate limits
- Network timeouts
- Pagination
- Empty responses
- Retry storms
- Non-JSON error responses
- Authentication failures
- Slow downstream services
- APIs returning
429 Too Many Requests - APIs returning
500,502,503, or504
In the previous article, we built a reusable REST client using:
- Java
HttpClient - Jackson
- Java records
- Generic response types
- Bearer-token authentication
In this tutorial, we'll make that client much more resilient.
We'll add:
- Centralized error handling
- Custom exceptions
- Safe retry logic
- Exponential backoff
-
Retry-Afterhandling - Rate-limit awareness
- Pagination
- Request timeouts
- Retryable vs non-retryable status codes
- Defensive JSON parsing
- Reusable API client architecture
By the end, we'll have a REST client that is much closer to something you could actually use in production.
1. Why a Basic REST Client Is Not Enough
A basic client often looks like this:
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() >= 200 &&
response.statusCode() < 300) {
return mapper.readValue(
response.body(),
responseType
);
}
throw new RuntimeException(
"HTTP " + response.statusCode()
);
That works when everything goes well.
But real APIs don't always behave perfectly.
What happens if the server returns:
503 Service Unavailable
Should we fail immediately?
Maybe not.
What about:
401 Unauthorized
Should we retry?
Usually no.
What about:
429 Too Many Requests
Should we retry immediately?
Definitely not.
What about:
504 Gateway Timeout
Maybe retrying is reasonable.
Production clients need rules.
2. Classify Errors Before Retrying
The first important idea is this:
Not every failure should be retried.
A useful classification looks like this:
2xx
→ success
4xx
→ usually client error
→ usually do not retry
429
→ rate limited
→ retry later
5xx
→ server error
→ some are retryable
Network failure
→ often retryable
Timeout
→ often retryable
A basic retryable status set might be:
429 Too Many Requests
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
We can encode that:
private boolean isRetryableStatus(int status) {
return status == 429 ||
status == 500 ||
status == 502 ||
status == 503 ||
status == 504;
}
This is much safer than retrying everything.
3. Create a Custom API Exception
Let's create an exception that keeps useful information.
public class ApiException extends RuntimeException {
private final int statusCode;
private final String responseBody;
public ApiException(
int statusCode,
String responseBody
) {
super(
"HTTP " +
statusCode +
": " +
responseBody
);
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public int statusCode() {
return statusCode;
}
public String responseBody() {
return responseBody;
}
}
Now our code can distinguish:
catch (ApiException e) {
if (e.statusCode() == 404) {
System.out.println(
"Resource not found"
);
}
}
That is better than throwing a generic RuntimeException.
4. Separate API Failures from Network Failures
This distinction matters.
An API failure means:
The server responded.
For example:
404
429
500
503
A network failure means:
The request did not complete normally.
For example:
DNS failure
connection reset
connection refused
socket error
timeout
So conceptually:
HTTP response received
↓
ApiException
No usable HTTP response
↓
IOException
This lets callers handle different problems differently.
5. Add Request Timeouts
Never assume a remote API will respond quickly.
Add a connection timeout to HttpClient:
HttpClient client = HttpClient.newBuilder()
.connectTimeout(
Duration.ofSeconds(10)
)
.build();
And a per-request timeout:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(
Duration.ofSeconds(20)
)
.GET()
.build();
These timeouts solve different problems.
Connection timeout
How long Java waits to establish a connection.
Request timeout
How long the request itself is allowed to take.
A useful mental model:
connectTimeout
→ "Can I connect?"
request timeout
→ "How long can this whole request take?"
6. Retry Logic: Start Simple
Let's create a method that retries a request.
private HttpResponse<String> sendWithRetry(
HttpRequest request
) throws IOException, InterruptedException {
int maxAttempts = 3;
for (int attempt = 1;
attempt <= maxAttempts;
attempt++) {
try {
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (!isRetryableStatus(
response.statusCode()
)) {
return response;
}
if (attempt == maxAttempts) {
return response;
}
Thread.sleep(1000);
} catch (IOException e) {
if (attempt == maxAttempts) {
throw e;
}
Thread.sleep(1000);
}
}
throw new IllegalStateException(
"Unexpected retry state"
);
}
This works.
But there is an immediate problem.
We always wait:
1 second
That's not ideal.
7. Use Exponential Backoff
A better retry pattern is:
attempt 1
↓
wait 1 second
attempt 2
↓
wait 2 seconds
attempt 3
↓
wait 4 seconds
attempt 4
↓
wait 8 seconds
This is called exponential backoff.
A simple implementation:
private long backoffMillis(
int attempt
) {
return 1000L *
(1L << (attempt - 1));
}
For:
attempt = 1
we get:
1000 ms
For:
attempt = 2
we get:
2000 ms
For:
attempt = 3
we get:
4000 ms
8. Add Jitter
If many application instances all retry at exactly the same time, you can create a retry storm.
Imagine:
100 servers
↓
all receive 503
↓
all wait exactly 2 seconds
↓
all retry at the same moment
That's bad.
A common improvement is jitter.
Jitter adds a small random delay.
private long backoffWithJitter(
int attempt
) {
long base =
1000L *
(1L << (attempt - 1));
long jitter =
ThreadLocalRandom.current()
.nextLong(0, 500);
return base + jitter;
}
Now waits might be:
1.23 seconds
2.41 seconds
4.09 seconds
instead of exactly:
1
2
4
This reduces synchronized retry spikes.
9. Respect Retry-After
When an API returns:
429 Too Many Requests
it may include:
Retry-After: 5
That means:
Try again after 5 seconds.
You should respect that when possible.
Example:
private long retryDelayMillis(
HttpResponse<String> response,
int attempt
) {
Optional<String> retryAfter =
response.headers()
.firstValue(
"Retry-After"
);
if (retryAfter.isPresent()) {
try {
long seconds =
Long.parseLong(
retryAfter.get()
);
return seconds * 1000L;
} catch (NumberFormatException ignored) {
}
}
return backoffWithJitter(
attempt
);
}
This handles the common numeric form.
10. Build a Better Retry Loop
Now we can combine everything.
private HttpResponse<String> sendWithRetry(
HttpRequest request
) throws IOException, InterruptedException {
int maxAttempts = 4;
for (int attempt = 1;
attempt <= maxAttempts;
attempt++) {
try {
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
int status =
response.statusCode();
if (!isRetryableStatus(status)) {
return response;
}
if (attempt == maxAttempts) {
return response;
}
long delay =
retryDelayMillis(
response,
attempt
);
Thread.sleep(delay);
} catch (IOException e) {
if (attempt == maxAttempts) {
throw e;
}
long delay =
backoffWithJitter(
attempt
);
Thread.sleep(delay);
}
}
throw new IllegalStateException(
"Unexpected retry state"
);
}
Now the client handles:
- Retryable server errors
429- Network failures
- Exponential backoff
- Jitter
Retry-After
11. Do Not Retry Every HTTP Method Blindly
This is one of the most important production concerns.
Retrying:
GET
is usually safer than retrying:
POST
Why?
Because a POST might have succeeded on the server even if your client never received the response.
Example:
Client sends payment request
↓
Server processes payment
↓
Connection drops
↓
Client retries
↓
Payment happens twice
That's a serious problem.
So retries should consider idempotency.
12. What Is Idempotency?
An idempotent request can be repeated without changing the result beyond the first application.
Commonly:
GET
PUT
DELETE
are designed to be idempotent.
POST usually is not.
So a safer default is:
private boolean isMethodRetryable(
String method
) {
return switch (method) {
case "GET",
"HEAD",
"PUT",
"DELETE",
"OPTIONS" -> true;
default -> false;
};
}
Then:
if (!isMethodRetryable(
request.method()
)) {
return client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
}
13. Retrying POST Requests Safely
Some APIs support idempotency keys.
For example:
Idempotency-Key: 72a9a9f8-...
You generate a unique ID:
String idempotencyKey =
UUID.randomUUID()
.toString();
Then send:
.header(
"Idempotency-Key",
idempotencyKey
)
The server can recognize repeated requests with the same key and avoid performing the operation twice.
This is commonly used in payment APIs and other sensitive operations.
If the API supports idempotency keys, POST retries can be much safer.
If it does not, be very careful.
14. Rate Limiting
Many APIs limit how many requests you can send.
For example:
100 requests per minute
If you exceed the limit, you may receive:
429 Too Many Requests
Some APIs expose headers like:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 3
X-RateLimit-Reset: 1699999999
There is no universal header format, but the idea is common.
You can inspect response headers:
response.headers()
.firstValue(
"X-RateLimit-Remaining"
)
.ifPresent(
value ->
System.out.println(
"Remaining: " +
value
)
);
At minimum, your client should correctly handle 429.
15. Rate Limit Handling Strategy
A simple strategy is:
response = 429
↓
read Retry-After
↓
wait
↓
retry
But you should also cap retries.
Never do:
retry forever
Use:
max attempts
For example:
private static final int MAX_ATTEMPTS = 4;
Without limits, a broken downstream API can keep your application stuck indefinitely.
16. Don't Sleep Forever
You should also cap the retry delay.
Suppose:
Retry-After: 7200
That means:
2 hours
You probably don't want one request thread sleeping for two hours.
So add a maximum:
private static final long MAX_RETRY_DELAY_MS =
30_000;
Then:
delay = Math.min(
delay,
MAX_RETRY_DELAY_MS
);
This keeps retry behavior bounded.
17. Pagination: Why It Matters
Many APIs do not return all records at once.
Suppose an API has:
100,000 users
It would be expensive to return all of them in one response.
Instead, the API might return:
page 1
page 2
page 3
...
For example:
GET /users?page=1&size=100
Then:
GET /users?page=2&size=100
This is called pagination.
18. Common Pagination Styles
There are several common styles.
Page number
?page=1&size=100
Offset + limit
?offset=0&limit=100
Cursor
?cursor=abc123
Link header
The server provides the next URL in an HTTP Link header.
Different APIs use different systems.
We'll start with page-number pagination.
19. Define a Paginated Response
Suppose the API returns:
{
"items": [
{
"id": 1,
"name": "Alice"
},
{
"id": 2,
"name": "Bob"
}
],
"page": 1,
"totalPages": 5
}
We can represent it using:
public record PageResponse<T>(
List<T> items,
int page,
int totalPages
) {
}
Because this contains a generic type, we'll use Jackson's TypeReference.
20. Fetch a Single Page
Suppose we have:
PageResponse<User> page =
client.get(
url,
new TypeReference<
PageResponse<User>
>() {}
);
Then:
page.items()
returns the users.
And:
page.totalPages()
tells us how many pages exist.
21. Fetch All Pages
We can write:
public List<User> getAllUsers()
throws IOException,
InterruptedException {
List<User> allUsers =
new ArrayList<>();
int page = 1;
while (true) {
String url =
baseUrl +
"/users?page=" +
page +
"&size=100";
PageResponse<User> result =
client.get(
url,
new TypeReference<
PageResponse<User>
>() {}
);
allUsers.addAll(
result.items()
);
if (page >=
result.totalPages()) {
break;
}
page++;
}
return allUsers;
}
This automatically walks through every page.
22. Be Careful with "Fetch Everything"
This looks convenient:
List<User> allUsers =
api.getAllUsers();
But imagine:
2 million users
Now you're loading them all into memory.
That may be a bad idea.
A more scalable design might:
- Process one page at a time
- Stream results
- Save each page
- Stop after a limit
- Expose pagination to the caller
So production code should think about data volume.
23. Cursor-Based Pagination
Cursor pagination is common in modern APIs.
A response might look like:
{
"items": [
{
"id": 1,
"name": "Alice"
}
],
"nextCursor": "eyJpZCI6MTAwfQ=="
}
Define:
public record CursorPage<T>(
List<T> items,
String nextCursor
) {
}
Then:
String cursor = null;
while (true) {
String url =
baseUrl + "/users";
if (cursor != null) {
url += "?cursor=" +
URLEncoder.encode(
cursor,
StandardCharsets.UTF_8
);
}
CursorPage<User> page =
client.get(
url,
new TypeReference<
CursorPage<User>
>() {}
);
process(page.items());
cursor =
page.nextCursor();
if (cursor == null ||
cursor.isBlank()) {
break;
}
}
Cursor pagination is often better when data changes frequently.
24. Why Cursor Pagination Can Be Better
Imagine page-number pagination:
page 1
page 2
page 3
While you're fetching pages, new records are inserted.
Now items may shift between pages.
You might:
- Miss records
- Process records twice
Cursor-based pagination often avoids this by giving you a stable continuation token.
25. Handle Empty Bodies Safely
Some successful responses contain no body.
For example:
204 No Content
So don't blindly parse every successful response.
Use:
private <T> T read(
HttpResponse<String> response,
Class<T> responseType
) throws IOException {
validate(response);
if (response.statusCode() == 204) {
return null;
}
String body =
response.body();
if (body == null ||
body.isBlank()) {
return null;
}
return mapper.readValue(
body,
responseType
);
}
This avoids unnecessary parsing failures.
26. Error Responses Might Not Be JSON
A REST API may normally return JSON:
{
"error": "User not found"
}
But a proxy might return:
<html>
<body>
502 Bad Gateway
</body>
</html>
So never assume error bodies are JSON.
This is especially important when parsing error payloads.
27. Parse Error JSON Defensively
Suppose your API normally returns:
{
"code": "USER_NOT_FOUND",
"message": "User 10 does not exist"
}
Create:
public record ErrorResponse(
String code,
String message
) {
}
Then:
private String extractErrorMessage(
String body
) {
if (body == null ||
body.isBlank()) {
return "No response body";
}
try {
ErrorResponse error =
mapper.readValue(
body,
ErrorResponse.class
);
if (error.message() != null) {
return error.message();
}
} catch (Exception ignored) {
}
return body;
}
This gives you structured errors when possible, and raw text otherwise.
28. Improve ApiException
We can now make the exception more useful:
public class ApiException
extends RuntimeException {
private final int statusCode;
private final String responseBody;
public ApiException(
int statusCode,
String message,
String responseBody
) {
super(message);
this.statusCode =
statusCode;
this.responseBody =
responseBody;
}
public int statusCode() {
return statusCode;
}
public String responseBody() {
return responseBody;
}
}
Then:
throw new ApiException(
response.statusCode(),
extractErrorMessage(
response.body()
),
response.body()
);
Now callers get both:
human-readable error
and:
raw response body
29. Validate Responses in One Place
Avoid repeating:
if (status < 200 ||
status >= 300)
everywhere.
Create:
private void validate(
HttpResponse<String> response
) {
int status =
response.statusCode();
if (status >= 200 &&
status < 300) {
return;
}
throw new ApiException(
status,
extractErrorMessage(
response.body()
),
response.body()
);
}
Now every API method behaves consistently.
30. Centralize Sending Too
We can also centralize:
client.send(...)
with retries.
private HttpResponse<String> send(
HttpRequest request
) throws IOException,
InterruptedException {
return sendWithRetry(
request
);
}
Then all methods use:
HttpResponse<String> response =
send(request);
This means retry behavior is implemented once.
31. Generic GET
Our GET method stays simple:
public <T> T get(
String url,
Class<T> responseType
) throws IOException,
InterruptedException {
HttpRequest request =
requestBuilder(url)
.GET()
.build();
HttpResponse<String> response =
send(request);
return read(
response,
responseType
);
}
32. Generic GET for Lists and Generic Types
Use:
public <T> T get(
String url,
TypeReference<T> responseType
) throws IOException,
InterruptedException {
HttpRequest request =
requestBuilder(url)
.GET()
.build();
HttpResponse<String> response =
send(request);
validate(response);
String body =
response.body();
if (body == null ||
body.isBlank()) {
return null;
}
return mapper.readValue(
body,
responseType
);
}
Now this works:
List<User> users =
client.get(
url,
new TypeReference<
List<User>
>() {}
);
33. Generic POST
public <T, R> R post(
String url,
T requestBody,
Class<R> responseType
) throws IOException,
InterruptedException {
String json =
mapper.writeValueAsString(
requestBody
);
HttpRequest request =
requestBuilder(url)
.header(
"Content-Type",
"application/json"
)
.POST(
HttpRequest.BodyPublishers
.ofString(json)
)
.build();
HttpResponse<String> response =
send(request);
return read(
response,
responseType
);
}
Remember:
POST retries should be treated carefully.
Our retry method should only retry POST automatically if we explicitly allow it.
34. Make Retry Behavior Explicit
Instead of guessing based only on the method, we can support a retry flag:
private HttpResponse<String> send(
HttpRequest request,
boolean allowRetry
) throws IOException,
InterruptedException {
if (!allowRetry) {
return client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
}
return sendWithRetry(
request
);
}
Then GET:
send(
request,
true
);
POST:
send(
request,
false
);
This makes behavior much more obvious.
35. Better: Create a Request Policy
As the client grows, a boolean becomes limiting.
We can create:
public record RequestPolicy(
boolean retry,
int maxAttempts
) {
}
For example:
RequestPolicy safeRetry =
new RequestPolicy(
true,
4
);
RequestPolicy noRetry =
new RequestPolicy(
false,
1
);
Then:
send(
request,
safeRetry
);
This becomes easier to extend later.
36. Production Retry Rules
A practical default might be:
GET
→ retry transient failures
HEAD
→ retry transient failures
PUT
→ usually retryable
DELETE
→ usually retryable
POST
→ no automatic retry
unless idempotency is guaranteed
PATCH
→ depends on API semantics
There is no universal rule.
The API contract matters.
37. Add a Base URL
Instead of passing:
"https://api.example.com/users/10"
everywhere, store:
private final String baseUrl;
Constructor:
public RestClient(
String baseUrl,
String token
) {
this.baseUrl =
baseUrl;
this.token =
token;
// ...
}
Then:
private URI uri(
String path
) {
return URI.create(
baseUrl + path
);
}
Usage:
client.get(
"/users/10",
User.class
);
Much cleaner.
38. Avoid Double Slashes
If:
baseUrl = https://api.example.com/
and:
path = /users
you get:
https://api.example.com//users
A simple helper:
private String normalizeUrl(
String baseUrl,
String path
) {
return baseUrl.replaceAll(
"/+$",
""
) +
"/" +
path.replaceAll(
"^/+",
""
);
}
Or normalize the base URL once in the constructor.
39. Add Default Headers
Your builder can centralize headers:
private HttpRequest.Builder requestBuilder(
String path
) {
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(
URI.create(
normalizeUrl(
baseUrl,
path
)
)
)
.timeout(
Duration.ofSeconds(20)
)
.header(
"Accept",
"application/json"
)
.header(
"User-Agent",
"MyJavaClient/1.0"
);
if (token != null &&
!token.isBlank()) {
builder.header(
"Authorization",
"Bearer " + token
);
}
return builder;
}
Now every request is consistent.
40. Log Carefully
Logging helps diagnose API problems.
Useful things to log include:
HTTP method
URL
status code
request duration
retry attempt
But be careful with:
Authorization headers
API tokens
passwords
personal data
payment information
full request bodies
Never blindly log everything.
A safe example:
System.out.println(
request.method() +
" " +
request.uri()
);
And:
System.out.println(
"HTTP " +
response.statusCode()
);
In real applications, use a logging framework rather than System.out.
41. Measure Request Duration
You can easily measure latency:
long start =
System.nanoTime();
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
long elapsedMillis =
Duration.ofNanos(
System.nanoTime() -
start
).toMillis();
Then:
System.out.println(
"Request took " +
elapsedMillis +
" ms"
);
This is useful for monitoring slow APIs.
42. Beware of Retry Multiplication
Suppose:
Service A
↓ retries 3 times
Service B
↓ retries 3 times
Service C
↓ retries 3 times
One user request can theoretically cause many downstream attempts.
Retries compound.
That's why retry counts should stay modest.
Usually:
2–4 attempts
is far more reasonable than:
20 attempts
43. Retries Are Not a Substitute for Resilience
Retries help with temporary failures.
They do not fix:
- Long outages
- Bad credentials
- Invalid input
- Broken API contracts
- Persistent rate limits
- Dependency overload
- Application bugs
Production systems often also use:
- Circuit breakers
- Queues
- Caching
- Bulkheads
- Fallbacks
- Monitoring
But those are separate concerns.
44. A More Complete Client
Here is a compact version combining many of the ideas from this tutorial.
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
public class RestClient {
private static final int MAX_ATTEMPTS = 4;
private static final long MAX_RETRY_DELAY_MS =
30_000;
private final HttpClient client;
private final ObjectMapper mapper;
private final String baseUrl;
private final String token;
public RestClient(
String baseUrl,
String token
) {
this.baseUrl =
baseUrl.replaceAll(
"/+$",
""
);
this.token =
token;
this.client =
HttpClient.newBuilder()
.connectTimeout(
Duration.ofSeconds(10)
)
.build();
this.mapper =
new ObjectMapper()
.configure(
DeserializationFeature
.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
}
public <T> T get(
String path,
Class<T> responseType
) throws IOException,
InterruptedException {
HttpRequest request =
requestBuilder(path)
.GET()
.build();
HttpResponse<String> response =
sendWithRetry(
request
);
return read(
response,
responseType
);
}
public <T> T get(
String path,
TypeReference<T> responseType
) throws IOException,
InterruptedException {
HttpRequest request =
requestBuilder(path)
.GET()
.build();
HttpResponse<String> response =
sendWithRetry(
request
);
validate(response);
String body =
response.body();
if (body == null ||
body.isBlank()) {
return null;
}
return mapper.readValue(
body,
responseType
);
}
public <T, R> R post(
String path,
T requestBody,
Class<R> responseType
) throws IOException,
InterruptedException {
String json =
mapper.writeValueAsString(
requestBody
);
HttpRequest request =
requestBuilder(path)
.header(
"Content-Type",
"application/json"
)
.POST(
HttpRequest.BodyPublishers
.ofString(json)
)
.build();
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
return read(
response,
responseType
);
}
private HttpRequest.Builder requestBuilder(
String path
) {
String url =
baseUrl +
"/" +
path.replaceAll(
"^/+",
""
);
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(
URI.create(url)
)
.timeout(
Duration.ofSeconds(20)
)
.header(
"Accept",
"application/json"
)
.header(
"User-Agent",
"JavaRestClient/1.0"
);
if (token != null &&
!token.isBlank()) {
builder.header(
"Authorization",
"Bearer " + token
);
}
return builder;
}
private HttpResponse<String> sendWithRetry(
HttpRequest request
) throws IOException,
InterruptedException {
for (int attempt = 1;
attempt <= MAX_ATTEMPTS;
attempt++) {
try {
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (!isRetryableStatus(
response.statusCode()
)) {
return response;
}
if (attempt ==
MAX_ATTEMPTS) {
return response;
}
sleep(
retryDelayMillis(
response,
attempt
)
);
} catch (IOException e) {
if (attempt ==
MAX_ATTEMPTS) {
throw e;
}
sleep(
backoffWithJitter(
attempt
)
);
}
}
throw new IllegalStateException(
"Unexpected retry state"
);
}
private boolean isRetryableStatus(
int status
) {
return status == 429 ||
status == 500 ||
status == 502 ||
status == 503 ||
status == 504;
}
private long retryDelayMillis(
HttpResponse<String> response,
int attempt
) {
Optional<String> retryAfter =
response.headers()
.firstValue(
"Retry-After"
);
if (retryAfter.isPresent()) {
try {
long seconds =
Long.parseLong(
retryAfter.get()
);
return Math.min(
seconds * 1000L,
MAX_RETRY_DELAY_MS
);
} catch (
NumberFormatException ignored
) {
}
}
return Math.min(
backoffWithJitter(
attempt
),
MAX_RETRY_DELAY_MS
);
}
private long backoffWithJitter(
int attempt
) {
long base =
1000L *
(1L <<
(attempt - 1));
long jitter =
ThreadLocalRandom
.current()
.nextLong(
0,
500
);
return base + jitter;
}
private void sleep(
long millis
) throws InterruptedException {
try {
Thread.sleep(
millis
);
} catch (
InterruptedException e
) {
Thread.currentThread()
.interrupt();
throw e;
}
}
private void validate(
HttpResponse<String> response
) {
int status =
response.statusCode();
if (status >= 200 &&
status < 300) {
return;
}
throw new ApiException(
status,
extractErrorMessage(
response.body()
),
response.body()
);
}
private <T> T read(
HttpResponse<String> response,
Class<T> responseType
) throws IOException {
validate(response);
String body =
response.body();
if (response.statusCode() == 204 ||
body == null ||
body.isBlank()) {
return null;
}
return mapper.readValue(
body,
responseType
);
}
private String extractErrorMessage(
String body
) {
if (body == null ||
body.isBlank()) {
return "No response body";
}
try {
ErrorResponse error =
mapper.readValue(
body,
ErrorResponse.class
);
if (error.message() != null &&
!error.message()
.isBlank()) {
return error.message();
}
} catch (Exception ignored) {
}
return body;
}
}
Supporting records:
public record ErrorResponse(
String code,
String message
) {
}
And:
public class ApiException
extends RuntimeException {
private final int statusCode;
private final String responseBody;
public ApiException(
int statusCode,
String message,
String responseBody
) {
super(message);
this.statusCode =
statusCode;
this.responseBody =
responseBody;
}
public int statusCode() {
return statusCode;
}
public String responseBody() {
return responseBody;
}
}
45. One Important Improvement to the Example
Notice that our retry method is used for GET:
sendWithRetry(request)
but POST uses:
client.send(...)
directly.
That is intentional.
We are being conservative.
GET requests are generally safe to retry.
POST requests may cause duplicate side effects.
If your API guarantees idempotency for a POST endpoint, then you can explicitly enable retry for that operation.
46. Example: Pagination Client
Suppose the API returns:
public record PageResponse<T>(
List<T> items,
int page,
int totalPages
) {
}
Then:
public List<User> getAllUsers()
throws IOException,
InterruptedException {
List<User> users =
new ArrayList<>();
int page = 1;
while (true) {
PageResponse<User> result =
client.get(
"/users?page=" +
page +
"&size=100",
new TypeReference<
PageResponse<User>
>() {}
);
users.addAll(
result.items()
);
if (page >=
result.totalPages()) {
break;
}
page++;
}
return users;
}
This gives the API-specific class responsibility for pagination, while the generic REST client handles transport.
47. Keep API-Specific Logic Outside the Generic Client
The generic REST client should know about:
HTTP
JSON
timeouts
retries
headers
status codes
It should not know:
what a User is
how GitHub paginates
how Stripe errors look
how a weather API structures pages
That belongs in API-specific classes.
For example:
UserService
↓
UserApi
↓
RestClient
Or:
GitHubApi
↓
RestClient
This keeps the generic client reusable.
48. A Clean Architecture
A production application might look like this:
Application / Service Layer
↓
GitHubApi
↓
RestClient
↓
┌───────────────────┐
│ HttpClient │
│ ObjectMapper │
│ retries │
│ backoff │
│ error handling │
│ authentication │
└───────────────────┘
↓
External API
Each layer has a clear responsibility.
49. Quick Retry Cheat Sheet
A practical retry policy might look like:
200–299
→ success
400
→ don't retry
401
→ don't retry
403
→ don't retry
404
→ don't retry
408
→ maybe retry
409
→ depends on API
429
→ retry after delay
500
→ retry
502
→ retry
503
→ retry
504
→ retry
But always check the API's own documentation.
50. Quick Production Checklist
Before calling your REST client production-ready, ask:
✓ Are connection timeouts configured?
✓ Are request timeouts configured?
✓ Are retry attempts bounded?
✓ Is exponential backoff used?
✓ Is jitter used?
✓ Is Retry-After respected?
✓ Are only appropriate requests retried?
✓ Are POST retries safe?
✓ Are 429 responses handled?
✓ Are 5xx failures handled?
✓ Are non-JSON errors tolerated?
✓ Are empty bodies handled?
✓ Is pagination implemented safely?
✓ Are tokens kept out of logs?
✓ Is HttpClient reused?
✓ Is ObjectMapper reused?
✓ Are interrupted threads re-interrupted?
✓ Are huge result sets prevented from exhausting memory?
If the answer is yes to most of these, you're already well beyond a basic tutorial client.
Final Thoughts
Building a REST client is easy.
Building one that behaves well when the network is slow, the API is overloaded, rate limits are reached, or data is paginated takes more thought.
The biggest production improvements are usually not complicated individually:
timeouts
retries
backoff
jitter
rate-limit handling
pagination
error classification
The real value comes from combining them consistently.
A useful mental model is:
Request
↓
Timeout protection
↓
Send
↓
Temporary failure?
↓ yes
Retry safely
↓
Backoff + jitter
↓
Respect rate limits
↓
Receive response
↓
Validate status
↓
Parse JSON
↓
Return Java object
And architecturally:
Application
↓
API-specific client
↓
Reusable RestClient
↓
HttpClient + Jackson
↓
External API
The goal is not to hide every possible failure.
The goal is to make failure behavior predictable.
That is what separates a demo REST client from a production-ready one.
About the Author
Deividas Strole is a Full-Stack Developer based in California, specializing in Java, Spring Boot, JavaScript, React, SQL, and AI-powered applications. He writes about software engineering, modern full-stack development, and digital marketing.
Connect with me:
Top comments (0)