CodeGym /Courses /Java Server /Non- 2xx responses and the error boundary

Non- 2xx responses and the error boundary

Java Server
Level 15, Lesson 3
Available

1. HttpResponse is not victory yet

When you start writing an HTTP client, your brain very quickly gets used to a simple logic: “if client.send(...) returned HttpResponse, then everything is fine.” And that is true... roughly 60% of the time. The response really arrived, which means we got through, agreed on the protocol, and received the status, headers, and body. But then the adult part begins: the server may have said, “I hear you, but I disagree.”

Imagine you order a pizza. If the phone does not answer, that is a transport problem: you did not even talk. If they answered and said, “we are not working today,” the conversation happened, but the result for you is still “no pizza.” In HTTP it is exactly the same: an exception during send() is “the phone does not answer,” while a HttpResponse with status 404 or 500 is “they answered, but refused.”

With timeouts, IOException, and InterruptedException we have already figured things out: there may have been no response at all. Here is a different branch. The HttpResponse has already arrived, but you can only treat it as success after checking the status.

The key idea here is simple: the presence of HttpResponse means the transport layer reached the remote service and received a response, but success is determined not by the fact of a response, but by its statusCode().

Here is a small example of what “naive” code looks like when it gets excited too early:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

// Important: send() can successfully return HttpResponse even for 404/500 — this is NOT an exception.
HttpResponse<String> response = client.send(
        HttpRequest.newBuilder(URI.create("https://catalog.example/books/NOPE"))
                .header("Accept", "application/json") // We ask for JSON, but that does not guarantee a successful status
                .GET()
                .build(),
        HttpResponse.BodyHandlers.ofString() // Read the body as a string, but it can also be an error
);

System.out.println(response.body()); // it may be not a book, but an error description

This kind of code often “works” right up until the first negative scenario. And negative scenarios in the network world show up quickly, like bank notifications after buying coffee.

2. Three possible outcomes: success and errors

Until we lay out the outcomes clearly, client code will either be too optimistic (“everything is success”) or too paranoid (“everything is an error”). In practice, we have three fundamentally different situations, and they differ by what exactly we have “in our hands” after the call.

Below is a table that helps avoid mixing up these cases (and avoids catching Exception for everything, as if it were a universal bandage).

Situation Do we have HttpResponse? What we know Typical example What to do in code
Successful response (2xx) Yes Success status, headers, body (usually JSON) 200 OK for search,
200 OK
for details
Allow reading body as “data”
Error response (non-2xx) Yes Error status, headers, body (often an error description) 404 Not Found,
429 Too Many Requests
,
500 Internal Server Error
Do not treat body as “data”; handle the status and report the error
Transport error (exception) No No response, only the fact of a problem (timeout, network, interruption) HttpTimeoutException,
IOException
Handle it as a connection failure; this is not a “status error,” there is no status at all

And here is a very useful (and honest) flowchart. It is handy to keep in mind when you write send():

flowchart TD
    A["client.send(request)"] -->|"HttpTimeoutException / IOException / InterruptedException"| B["Transport error: no response"]
    A -->|received HttpResponse| C{"statusCode 2xx?"}
    C -->|yes| D["Success: body can be read as data"]
    C -->|no| E["HTTP error: status != 2xx, body is not data"]

If you remember only this, it already becomes easier. Because the main beginner confusion looks like this: “I got JSON in the body, so everything is fine.” No. JSON can be in a successful response or in an error response too. Status is the main branch selector.

3. Check the 200..299 range, not just 200

When you write a client for a specific scenario (for example, a GET search), a 200 often comes back. But as soon as you start thinking “like a transport layer developer,” you need to be a bit broader: successful statuses are not one number, but the 2xx range.

If you check only status == 200, you are creating a little bug lottery for yourself. For example, 201 Created is also a success, just a different kind of success. 204 No Content is success without a body. And yes, even if today your provider always returns 200, tomorrow it may change (or you may go to a different endpoint).

A minimal but very useful function:

private static boolean is2xx(int status) {
    // 2xx is a success range, not a single 200 value
    return status >= 200 && status < 300;
}

Now you can phrase any success check in a more “grown-up” way:

int status = response.statusCode(); // Status decides: success or error

if (!is2xx(status)) {
    // Do not let body pass through as "data" if the status is unsuccessful
    System.out.println("Catalog returned an error: " + status);
    return;
}

System.out.println("Success: " + response.body()); // Here body can already be treated as data

Notice: here we did an important thing — we did not let body pass through until we made sure the status was successful. This is a simple habit that saves a lot of nerves when you later start turning body into DTOs (but that will not be today).

And one more small nuance: when you use HttpResponse.BodyHandlers.ofString(), then for 204 you will get an empty string. And that is normal: “no content” is a legitimate success. For our book catalog, 204 is unlikely to occur, but for general transport it does happen.

4. Body on errors: not data, but a hint

When the status is not 2xx, there is a temptation to do one of two extremes. The first extreme is to ignore the body completely: “an error is an error.” The second extreme is to happily try to read the body as if it were normal data: “well, it is text/JSON, so it must be fine.”

The right position is somewhere in the middle: the body on error is often very useful for diagnostics, but it is not happy-path data. For our ReadLater Starter project, a simple rule is enough for now: on non-2xx we may keep or show the body as a hint (within reasonable limits), but we do not continue processing as if it were the result of a search or details request.

To make “within reasonable limits” more than just words, let us add a small helper that trims overly long content. This saves the console from turning into a scroll of 300 screens:

private static String shorten(String text, int maxLen) {
    // On errors the server may send anything and a lot of it — limit the length
    if (text == null) return "<no body>";
    if (text.length() <= maxLen) return text;

    // Return only the beginning so the output does not turn into a "sheet"
    return text.substring(0, maxLen) + "...";
}

Now, on error, you can print something neat:

int status = response.statusCode(); // First, look at the status

if (!is2xx(status)) {
    // Print the body as a hint (diagnostics), not as "data"
    System.out.println("Catalog error: " + status);
    System.out.println("Body fragment: " + shorten(response.body(), 200));
}

It is also useful to remember that the server may suddenly send not JSON on error, even if you asked for JSON with Accept: application/json. Sometimes it is a proxy HTML page, sometimes plain text, sometimes “something weird.” That is why headers sometimes help in diagnostics:

String contentType = response.headers()
        .firstValue("Content-Type") // The header may be missing — so we read it through Optional
        .orElse("<unknown>");

System.out.println("Content-Type = " + contentType); // Content-Type = application/json

Let me remind you again: we are not building the “perfect error parser” right now. We are building a basic habit: the status decides the branch, and the body on error is a hint, not the result.

5. 4xx and 5xx: minimal response

When we see non-2xx, that does not yet mean we need an encyclopedia of statuses. But there is one very useful split that helps both you and the user: client errors and server errors.

4xx usually means: “the request is bad, or the resource was not found, or you cannot do that.” A common example for a catalog is an incorrect externalId, and the server honestly says 404 Not Found. This is not “the provider is broken,” this is “that book does not exist.”

5xx means: “the service itself could not process it.” This is closer to “the provider is sick,” and your code often cannot do much about it.

In the project, it is convenient to have a very simple translation from status to a user message (for now without deep body analysis):

private static String userMessageForStatus(int status) {
    // Here we do not build an "HTTP reference", we give the person a clear explanation
    if (status == 404) return "Book not found in the catalog";
    if (status >= 500) return "Catalog is temporarily unavailable (server error)";

    // For other statuses — at least show the code so there is something to anchor diagnostics on
    return "Catalog returned an error: " + status;
}

Why is this useful even “before the nice architecture”? Because our ReadLater Starter currently runs as a console application with commands. The user (that is, you) should understand what happened: “I got the ID wrong” or “the provider is down.”

And a careful note about 3xx: Java HttpClient does not follow redirects by default. If you accidentally use http://... instead of https://..., the provider may answer with 301/302. For a normal JSON API, that is often a signal that the baseUrl is wrong, and it is better to fix it rather than try to “read the redirect as data.”

6. Transport rule: body only on 2xx

When you write several requests (search, details, maybe more later), the same status-check logic starts to repeat. And any duplication in code has the funny property that when you fix a bug in one place, it will definitely remain in another, just so things do not get boring.

So the right next step is to make one place where the “success/failure” rule lives. We are not yet extracting everything into a separate CatalogClient (that will be the next lecture), but the rule can already be centralized in a helper method.

Let us introduce a small exception so we do not lose the status:

public class CatalogApiException extends RuntimeException {
    // We keep the status code so it can be used in a user message/processing logic
    private final int status;

    public CatalogApiException(int status, String message) {
        // message here is a diagnostic hint (for example, a shortened body)
        super(message);
        this.status = status;
    }

    public int status() {
        return status;
    }
}

And now the method that either returns the body or throws CatalogApiException:

import java.net.http.HttpResponse;

private static String require2xx(HttpResponse<String> response) {
    int status = response.statusCode(); // Read the status once so we do not repeat ourselves
    if (is2xx(status)) {
        // Only on success do we treat body as "data"
        return response.body();
    }

    // On error, throw an exception with the status and diagnostics (a shortened body fragment)
    throw new CatalogApiException(status, shorten(response.body(), 200));
}

Notice that this method is almost “mathematical”: input — HttpResponse<String>, output — a string, but only on success. Everything else is an exception. This style sharply reduces the number of “checks in place” and makes the code a little more linear: you either get data, or you do not.

And this is not some “exception religion,” but simple transport discipline: the transport layer must not let erroneous responses pass through as if they were data.

7. In ReadLater Starter: printing errors

Now let us combine everything into a small scenario similar to what our app does: send a request, receive a response, check the status, print the result, and if there is an error — show a clear message.

Schematically, this can live in ReadLaterApplication (or whatever your entrypoint is called) for now right in the “command logic”:

import java.io.IOException;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

try {
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
    String body = require2xx(response);

    System.out.println(body); // print JSON as a string (for now)
} catch (CatalogApiException e) {
    System.out.println(userMessageForStatus(e.status())); // Book not found in the catalog
} catch (HttpTimeoutException e) {
    System.out.println("The catalog is taking too long to respond");
} catch (IOException e) {
    System.out.println("Network error: " + e.getMessage());
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    System.out.println("The request was interrupted");
}

What matters here is the boundary: CatalogApiException means “the response arrived, but the status is bad,” while the other catch blocks mean that we never even reached a response we could work with. It is a different branch, a different meaning, a different message.

If you collapse this back into a single catch (Exception), you lose an important signal: what exactly happened — did the network fail to give an answer, or did the server answer with a refusal? And then you wonder why “everything looks the same,” even though the causes are different. Spoiler: because you wrote it that way.

8. Common mistakes with non-2xx

Mistake No. 1: treating any non-empty body as success.
This is the most common trap: the server returned 404, but the body contains JSON with an error description, and your hand reaches to parse it “like a normal response.” The result is that you either show the user nonsense or later get a deserialization error. The right habit is: status first, then body as data.

Mistake No. 2: checking only status == 200.
While you are doing one GET, it may seem like “good enough.” But transport code is part of the application’s skeleton: it should be a little more universal. Checking 200..299 is no harder, but much more reliable. Otherwise you are planting bugs right away as soon as you encounter 201 or 204 (or a redirect 301 because of the wrong baseUrl).

Mistake No. 3: mixing a transport error and an HTTP error into one mess.
IOException and HttpResponse with status 500 are different stories. In the first case there is no response at all, in the second there is a response, it just says “error.” If you tell the user (or yourself) “something broke” in both cases in the same way, you lose the chance to quickly understand where the problem is: in the network or in the remote service.

Mistake No. 4: losing the status code when forming an error message.
If you throw new IllegalStateException("Error") without a status, you cut off half the meaning. The status is a short but very informative summary. Even if you do not know how to parse body nicely, the status already gives direction: 404 — “not found,” 429 — “too many requests,” 500 — “they have a fire over there.”

Mistake No. 5: printing a giant body to the user “as is.”
Sometimes the provider returns a huge HTML page with a stack trace or a proxy error page. If you blindly print that to the console, you turn the output into an unreadable stream. A normal strategy for a learning client is to limit the length and show only a fragment. You will log the full body later through a proper logging stack, not through a “sheet” in the console.

1
Task
Java Server, level 15, lesson 3
Locked
Success by the 2xx range, not only by 200
Success by the 2xx range, not only by 200
1
Task
Java Server, level 15, lesson 3
Locked
Service response and transport failure classification
Service response and transport failure classification
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION