DEV Community

Cover image for Production-Ready Java REST Client: Retries, Pagination, Rate Limits, and Error Handling
Deividas Strole
Deividas Strole

Posted on

Production-Ready Java REST Client: Retries, Pagination, Rate Limits, and Error Handling

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, or 504

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-After handling
  • 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()
);
Enter fullscreen mode Exit fullscreen mode

That works when everything goes well.

But real APIs don't always behave perfectly.

What happens if the server returns:

503 Service Unavailable
Enter fullscreen mode Exit fullscreen mode

Should we fail immediately?

Maybe not.

What about:

401 Unauthorized
Enter fullscreen mode Exit fullscreen mode

Should we retry?

Usually no.

What about:

429 Too Many Requests
Enter fullscreen mode Exit fullscreen mode

Should we retry immediately?

Definitely not.

What about:

504 Gateway Timeout
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A basic retryable status set might be:

429 Too Many Requests
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
Enter fullscreen mode Exit fullscreen mode

We can encode that:

private boolean isRetryableStatus(int status) {
    return status == 429 ||
           status == 500 ||
           status == 502 ||
           status == 503 ||
           status == 504;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now our code can distinguish:

catch (ApiException e) {

    if (e.statusCode() == 404) {
        System.out.println(
                "Resource not found"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

For example:

404
429
500
503
Enter fullscreen mode Exit fullscreen mode

A network failure means:

The request did not complete normally.
Enter fullscreen mode Exit fullscreen mode

For example:

DNS failure
connection reset
connection refused
socket error
timeout
Enter fullscreen mode Exit fullscreen mode

So conceptually:

HTTP response received
    ↓
ApiException

No usable HTTP response
    ↓
IOException
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

And a per-request timeout:

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .timeout(
                Duration.ofSeconds(20)
        )
        .GET()
        .build();
Enter fullscreen mode Exit fullscreen mode

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?"
Enter fullscreen mode Exit fullscreen mode

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

This works.

But there is an immediate problem.

We always wait:

1 second
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is called exponential backoff.

A simple implementation:

private long backoffMillis(
        int attempt
) {

    return 1000L *
            (1L << (attempt - 1));
}
Enter fullscreen mode Exit fullscreen mode

For:

attempt = 1
Enter fullscreen mode Exit fullscreen mode

we get:

1000 ms
Enter fullscreen mode Exit fullscreen mode

For:

attempt = 2
Enter fullscreen mode Exit fullscreen mode

we get:

2000 ms
Enter fullscreen mode Exit fullscreen mode

For:

attempt = 3
Enter fullscreen mode Exit fullscreen mode

we get:

4000 ms
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Now waits might be:

1.23 seconds
2.41 seconds
4.09 seconds
Enter fullscreen mode Exit fullscreen mode

instead of exactly:

1
2
4
Enter fullscreen mode Exit fullscreen mode

This reduces synchronized retry spikes.


9. Respect Retry-After

When an API returns:

429 Too Many Requests
Enter fullscreen mode Exit fullscreen mode

it may include:

Retry-After: 5
Enter fullscreen mode Exit fullscreen mode

That means:

Try again after 5 seconds.
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

is usually safer than retrying:

POST
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    };
}
Enter fullscreen mode Exit fullscreen mode

Then:

if (!isMethodRetryable(
        request.method()
)) {

    return client.send(
            request,
            HttpResponse.BodyHandlers.ofString()
    );
}
Enter fullscreen mode Exit fullscreen mode

13. Retrying POST Requests Safely

Some APIs support idempotency keys.

For example:

Idempotency-Key: 72a9a9f8-...
Enter fullscreen mode Exit fullscreen mode

You generate a unique ID:

String idempotencyKey =
        UUID.randomUUID()
                .toString();
Enter fullscreen mode Exit fullscreen mode

Then send:

.header(
        "Idempotency-Key",
        idempotencyKey
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If you exceed the limit, you may receive:

429 Too Many Requests
Enter fullscreen mode Exit fullscreen mode

Some APIs expose headers like:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 3
X-RateLimit-Reset: 1699999999
Enter fullscreen mode Exit fullscreen mode

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
                        )
        );
Enter fullscreen mode Exit fullscreen mode

At minimum, your client should correctly handle 429.


15. Rate Limit Handling Strategy

A simple strategy is:

response = 429
    ↓
read Retry-After
    ↓
wait
    ↓
retry
Enter fullscreen mode Exit fullscreen mode

But you should also cap retries.

Never do:

retry forever
Enter fullscreen mode Exit fullscreen mode

Use:

max attempts
Enter fullscreen mode Exit fullscreen mode

For example:

private static final int MAX_ATTEMPTS = 4;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That means:

2 hours
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Then:

delay = Math.min(
        delay,
        MAX_RETRY_DELAY_MS
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It would be expensive to return all of them in one response.

Instead, the API might return:

page 1
page 2
page 3
...
Enter fullscreen mode Exit fullscreen mode

For example:

GET /users?page=1&size=100
Enter fullscreen mode Exit fullscreen mode

Then:

GET /users?page=2&size=100
Enter fullscreen mode Exit fullscreen mode

This is called pagination.


18. Common Pagination Styles

There are several common styles.

Page number

?page=1&size=100
Enter fullscreen mode Exit fullscreen mode

Offset + limit

?offset=0&limit=100
Enter fullscreen mode Exit fullscreen mode

Cursor

?cursor=abc123
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

We can represent it using:

public record PageResponse<T>(
        List<T> items,
        int page,
        int totalPages
) {
}
Enter fullscreen mode Exit fullscreen mode

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>
                >() {}
        );
Enter fullscreen mode Exit fullscreen mode

Then:

page.items()
Enter fullscreen mode Exit fullscreen mode

returns the users.

And:

page.totalPages()
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This automatically walks through every page.


22. Be Careful with "Fetch Everything"

This looks convenient:

List<User> allUsers =
        api.getAllUsers();
Enter fullscreen mode Exit fullscreen mode

But imagine:

2 million users
Enter fullscreen mode Exit fullscreen mode

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=="
}
Enter fullscreen mode Exit fullscreen mode

Define:

public record CursorPage<T>(
        List<T> items,
        String nextCursor
) {
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

This avoids unnecessary parsing failures.


26. Error Responses Might Not Be JSON

A REST API may normally return JSON:

{
  "error": "User not found"
}
Enter fullscreen mode Exit fullscreen mode

But a proxy might return:

<html>
  <body>
    502 Bad Gateway
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Create:

public record ErrorResponse(
        String code,
        String message
) {
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then:

throw new ApiException(
        response.statusCode(),
        extractErrorMessage(
                response.body()
        ),
        response.body()
);
Enter fullscreen mode Exit fullscreen mode

Now callers get both:

human-readable error
Enter fullscreen mode Exit fullscreen mode

and:

raw response body
Enter fullscreen mode Exit fullscreen mode

29. Validate Responses in One Place

Avoid repeating:

if (status < 200 ||
    status >= 300)
Enter fullscreen mode Exit fullscreen mode

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()
    );
}
Enter fullscreen mode Exit fullscreen mode

Now every API method behaves consistently.


30. Centralize Sending Too

We can also centralize:

client.send(...)
Enter fullscreen mode Exit fullscreen mode

with retries.

private HttpResponse<String> send(
        HttpRequest request
) throws IOException,
         InterruptedException {

    return sendWithRetry(
            request
    );
}
Enter fullscreen mode Exit fullscreen mode

Then all methods use:

HttpResponse<String> response =
        send(request);
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

Now this works:

List<User> users =
        client.get(
                url,
                new TypeReference<
                        List<User>
                >() {}
        );
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

Then GET:

send(
    request,
    true
);
Enter fullscreen mode Exit fullscreen mode

POST:

send(
    request,
    false
);
Enter fullscreen mode Exit fullscreen mode

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
) {
}
Enter fullscreen mode Exit fullscreen mode

For example:

RequestPolicy safeRetry =
        new RequestPolicy(
                true,
                4
        );

RequestPolicy noRetry =
        new RequestPolicy(
                false,
                1
        );
Enter fullscreen mode Exit fullscreen mode

Then:

send(
        request,
        safeRetry
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

There is no universal rule.

The API contract matters.


37. Add a Base URL

Instead of passing:

"https://api.example.com/users/10"
Enter fullscreen mode Exit fullscreen mode

everywhere, store:

private final String baseUrl;
Enter fullscreen mode Exit fullscreen mode

Constructor:

public RestClient(
        String baseUrl,
        String token
) {

    this.baseUrl =
            baseUrl;

    this.token =
            token;

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Then:

private URI uri(
        String path
) {

    return URI.create(
            baseUrl + path
    );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

client.get(
        "/users/10",
        User.class
);
Enter fullscreen mode Exit fullscreen mode

Much cleaner.


38. Avoid Double Slashes

If:

baseUrl = https://api.example.com/
Enter fullscreen mode Exit fullscreen mode

and:

path = /users
Enter fullscreen mode Exit fullscreen mode

you get:

https://api.example.com//users
Enter fullscreen mode Exit fullscreen mode

A simple helper:

private String normalizeUrl(
        String baseUrl,
        String path
) {

    return baseUrl.replaceAll(
            "/+$",
            ""
    ) +
    "/" +
    path.replaceAll(
            "^/+",
            ""
    );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But be careful with:

Authorization headers
API tokens
passwords
personal data
payment information
full request bodies
Enter fullscreen mode Exit fullscreen mode

Never blindly log everything.

A safe example:

System.out.println(
        request.method() +
        " " +
        request.uri()
);
Enter fullscreen mode Exit fullscreen mode

And:

System.out.println(
        "HTTP " +
        response.statusCode()
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Then:

System.out.println(
        "Request took " +
        elapsedMillis +
        " ms"
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

One user request can theoretically cause many downstream attempts.

Retries compound.

That's why retry counts should stay modest.

Usually:

2–4 attempts
Enter fullscreen mode Exit fullscreen mode

is far more reasonable than:

20 attempts
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Supporting records:

public record ErrorResponse(
        String code,
        String message
) {
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

45. One Important Improvement to the Example

Notice that our retry method is used for GET:

sendWithRetry(request)
Enter fullscreen mode Exit fullscreen mode

but POST uses:

client.send(...)
Enter fullscreen mode Exit fullscreen mode

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
) {
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It should not know:

what a User is
how GitHub paginates
how Stripe errors look
how a weather API structures pages
Enter fullscreen mode Exit fullscreen mode

That belongs in API-specific classes.

For example:

UserService
    ↓
UserApi
    ↓
RestClient
Enter fullscreen mode Exit fullscreen mode

Or:

GitHubApi
    ↓
RestClient
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And architecturally:

Application
    ↓
API-specific client
    ↓
Reusable RestClient
    ↓
HttpClient + Jackson
    ↓
External API
Enter fullscreen mode Exit fullscreen mode

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)