1. What an HTTP response consists of
When you start working with external APIs, it is very easy to fall into a trap: “I need JSON, so I take response.body() — and happiness is here.” This is roughly like receiving a paper letter and immediately reading the main message, ignoring the subject, sender, address, and notes on the envelope. Sometimes it works. And sometimes it turns out that the letter actually says “Return to sender. Recipient not found,” just written in beautiful handwriting.
An HTTP response is always structured the same way: there is a status (one number that briefly says “success/error and what kind”), there are headers (metadata about the format, length, time, content type, and so on), and there is a body (payload — often JSON, but not always). In Java, this is almost mirrored in HttpResponse<String>: statusCode() gives the status, headers() gives the headers, and body() gives the body in the form you requested through the body handler.
To lock in the “HTTP reality ↔ Java methods” mapping, it is handy to keep a small table in mind:
| Part of the response | What it means in human terms | Where it is in HttpResponse<String> |
|---|---|---|
| Status code | “How the server evaluates the request” (success, client error, server error) | response.statusCode() |
| Headers | “Service information: what the body is, how to read it, what rules apply” | response.headers() |
| Body | “Content: data or an error message” | response.body() |
If you remember only one idea from this lecture, let it be the order: status first, headers second, body third. It is like safety rules: first we look at the “voltage” sign, then we touch the wiring.
Visually, it can be represented like this:
flowchart TD
A["We sent HttpRequest"] --> B["Got HttpResponse<String>"]
B --> C["1) statusCode()"]
C --> D["2) headers()"]
D --> E["3) body()"]
E --> F["Show the user / diagnose the contract"]
2. status code: the quick result of the request
A status code is the fastest way to understand what happened. And this is not a “formality” or “for looks”: the status is a separate channel of meaning. The server may return JSON that looks “almost normal,” but if the status is wrong, that is already a different scenario. In integrations, it is often not the “data” that breaks, but the “meaning”: you think you received a book card, but in fact you got an error like “not found” or “too many requests.”
In an educational context, a simple practical model is enough: 2xx means success, everything else is a reason to stop and at least inspect what is wrong. Yes, there are 3xx, redirects, and other nice things in the world, but today we are focusing on the basic discipline of reading a response.
To make it easier for students, you can use status “classes” (the first digit):
| Class | Example | Meaning in two words |
|---|---|---|
| 2xx | 200 | Success |
| 4xx | 404 | Problem in the request / resource from the client’s point of view |
| 5xx | 500 | Problem on the server side |
Now let’s see how to express this in code without turning everything into a huge error-handling system (we do not need that right now). Let us start with the simplest thing: printing the status.
import java.net.http.HttpResponse;
// We got response after client.send(...) — this is our “envelope” with the response
HttpResponse<String> response = /* got after client.send(...) */;
// Status is the first thing worth looking at: “success/error and what kind”
int status = response.statusCode();
System.out.println("HTTP " + status); // HTTP 200 (or another status)
It seems trivial, but this really turns “some string” into “I can see what the server said about the request.” Next comes the minimal “successful or not” check:
// Take the status separately so you do not lose the meaning of the response
int status = response.statusCode();
// We consider 2xx as success, everything else is a reason to slow down and diagnose
if (status >= 200 && status < 300) {
System.out.println("Request succeeded"); // Request succeeded
} else {
System.out.println("Request failed: HTTP " + status); // Request failed: HTTP 404
}
And there is another trick that backend developers love for its brevity: divide by 100. This helps you read the status class without memorizing ranges.
int status = response.statusCode();
// Divide by 100 to get the class: 2xx/4xx/5xx, etc.
int statusClass = status / 100;
if (statusClass == 2) {
System.out.println("2xx success"); // 2xx success
} else if (statusClass == 4) {
System.out.println("4xx client-side problem"); // 4xx client-side problem
} else if (statusClass == 5) {
System.out.println("5xx server-side problem"); // 5xx server-side problem
}
Important: this is not “ideal” error handling. It is just a disciplined way not to miss reality. In the ReadLater Starter project, this discipline is especially useful because we are calling an external book catalog: if it returns 404 or 503, you should see that immediately instead of “parsing JSON” and wondering why the fields are wrong.
3. headers: response metadata
Headers are often seen as something secondary: “well, there are some strings there…” But for an HTTP client, headers are hints: what is inside the body, how it is encoded, what format to expect, and sometimes even why the server made a certain decision. And in an educational project they are useful at least as a contract check: did we really receive JSON, or an HTML page with “Access Denied” disguised as pretty text?
In Java, headers are not just “a Map”; they are in the HttpHeaders object (method response.headers()). It has a handy feature: you can get all headers at once (map()), or a specific header (firstValue(...), allValues(...)). And here is an important nuance: one header can sometimes have several values, so at the “map” level you see Map<String, List<String>>.
Let us start by simply looking at all headers in one call:
// Quick “X-ray”: print all headers at once (key -> list of values)
System.out.println(response.headers().map());
// {content-type=[application/json], date=[...], ...}
This is a quick “X-ray”: you do not have to understand every header, but you already see that, for example, content-type is present and looks like JSON.
If you want to print the headers nicely and readably (without writing 50 lines), you can do this:
// Go through all headers: one name can have multiple values
response.headers().map().forEach((name, values) -> {
// Join the list of values into one string for convenient output
String joined = String.join(", ", values);
System.out.println(name + ": " + joined);
});
// content-type: application/json
// date: Tue, 19 Mar 2026 12:00:00 GMT
Note: values is a list. So String.join(", ", values) is very handy here.
Now the most practical thing for us: the Content-Type header. It answers the question: “What is in the body?” If Content-Type = application/json, the chance that the body really contains JSON goes up sharply. If it is text/html, then most likely you got an HTML page back (possibly an error or a stub).
Let us extract Content-Type safely (it may be absent, especially in strange responses):
// firstValue(...) returns Optional: the header may be missing
String contentType = response.headers()
.firstValue("Content-Type")
.orElse("<no Content-Type>");
System.out.println("Content-Type: " + contentType);
// Content-Type: application/json; charset=utf-8
A small but important detail: Content-Type often contains not only the type but also additional parameters, for example charset. So it is better to check for JSON with contains("application/json"), not strict equality.
String contentType = response.headers()
.firstValue("Content-Type")
.orElse("");
// Check “looks like JSON”: account for parameters such as charset
boolean jsonLike = contentType.contains("application/json");
System.out.println("Is JSON: " + jsonLike); // Is JSON: true
Another detail that beginners sometimes trip over: header names in HTTP are case-insensitive, but when you print headers().map(), keys may be normalized in some way (often to lowercase). So do not be surprised if you expected Content-Type but see content-type. When reading through firstValue("Content-Type"), you can use the familiar name — Java will handle it correctly.
4. Raw body: string and preview
The response body is what we made the request for in the first place. But “body” is a tricky concept: it can be short, it can be huge, it can be JSON, or it can be anything at all (for example, an error text, HTML, plain text). And even more importantly: the body should always be read in the context of the status and headers, otherwise you risk interpreting “the wrong thing.”
In our model today, HttpResponse<String> body is just a string:
// In this model, body is already a string (we chose the corresponding body handler)
String body = response.body();
// It is useful to understand the size before printing “everything at once”
System.out.println("Body length: " + body.length()); // Body length: 8421
If you now do System.out.println(body), then at best you will drown in JSON, and at worst you will get such a large output that you will stop seeing the important things, such as the status and headers. So in practice, people often make a preview: they print the first N characters to visually check whether it looks like what they expected.
Here is the safest way to make a preview and avoid StringIndexOutOfBoundsException:
String body = response.body();
int max = 200;
// Trim safely: if the body is shorter than max, take the full length
String preview = body.substring(0, Math.min(body.length(), max));
System.out.println("Body preview: " + preview);
// Body preview: {"items":[{"title":"Clean Code",...
If you want something a bit neater (so it is visible that the text has been truncated), you can make a small utility:
private static String preview(String text, int maxLen) {
// Protection from null: sometimes the body handler / surrounding code can give null
if (text == null) return "<null>";
// If the text is short, return it as is
if (text.length() <= maxLen) return text;
// If it is long, trim it and add “...”, so the truncation is obvious
return text.substring(0, maxLen) + "...";
}
And use it like this:
String body = response.body();
// Print only the beginning so you do not flood the console
System.out.println(preview(body, 200));
// {"items":[{"title":"Clean Code",...}
Pay attention to a very useful combination: first look at Content-Type, then decide what to do with the body. For example, if Content-Type does not look like JSON, you can avoid trying to be clever and honestly print a preview as text for diagnostics.
String contentType = response.headers().firstValue("Content-Type").orElse("");
String body = response.body();
// If the type does not look like JSON, print a diagnostic message and a body snippet
if (!contentType.contains("application/json")) {
System.out.println("Unexpected body type: " + contentType);
System.out.println("Body preview: " + preview(body, 200));
}
This check does not make your client “smart.” It makes it not naive. And that, believe me, is already half the battle at the integration level.
And one more thought that fits the ReadLater Starter project well: raw body is convenient as a “first contract check.” That is, we do not map JSON, we do not build DTOs, we do not format things nicely — we just make sure that the response actually arrived, the status is reasonable, the format resembles what we expected, and the body looks like JSON. It is like checking that a bicycle has wheels before discussing what stickers they should have.
5. Diagnostics: response summary
When you make your first external calls, it is very useful to have one small piece of code that prints “the most important things” from a HttpResponse<String>. This helps keep the mechanics visible without turning every place in the app into copy-paste from statusCode()/headers()/substring().
Let us start with a mini-method called “print a short report” — it is intentionally simple. Its idea is to give you a quick look at the response without turning this step into a separate architecture.
import java.net.http.HttpResponse;
public static void printSummary(HttpResponse<String> response) {
// 1) Status: overall “success/error”
int status = response.statusCode();
// 2) Key header: what kind of data is in the body
String contentType = response.headers().firstValue("Content-Type").orElse("<none>");
// 3) Body: print only the beginning so you do not drown in output
String bodyPreview = preview(response.body(), 200);
System.out.println("HTTP " + status + ", Content-Type=" + contentType);
System.out.println("Body: " + bodyPreview);
}
Here we have exactly what we need today: the status, one key header, and the “first 200 characters.” And no “universal frameworks” that usually make things worse at the first step: you no longer understand what actually came back, and why.
If you want, you can add another layer of meaning: print “OK” or “FAIL” based on the status. This helps when you run the application quickly and do not want to search for HTTP 200 in the middle of everything else.
int status = response.statusCode();
// Simple label: success means only 2xx
boolean ok = status >= 200 && status < 300;
System.out.println((ok ? "[OK] " : "[FAIL] ") + "HTTP " + status);
// [OK] HTTP 200
From the perspective of ReadLater Starter, it will look like this: you sent a search request, got HttpResponse<String>, called printSummary(response), and saw in the console that it is indeed 200, that Content-Type looks like JSON, and that the body starts like the expected JSON. For today, that is already a great result: you are not “believing,” you are “seeing.”
6. Typical mistakes when reading a response
Mistake #1: looking only at body() and ignoring statusCode().
It is very easy to get into the mode of “I need JSON — give me JSON,” and immediately print response.body(). The problem is that a body without a status loses meaning. Even if the server returned JSON, it may be a JSON error, a JSON stub, or the result of a completely different scenario. The habit of “status first” almost always saves hours of debugging.
Mistake #2: printing the entire response body to the console.
At first it seems logical to do System.out.println(response.body()) to “see what came back.” But real responses are often large, and you will drown in the data yourself. Even worse: in the middle of a giant JSON, you will stop seeing important things — the status, Content-Type, your own diagnostic messages. A preview of 100–200 characters gives exactly what you need for the initial check, and does not turn the console into a mess.
Mistake #3: calling substring(0, 200) without checking the string length.
This is a classic: on the happy path everything is fine, and then you hit a short response (or an empty body), and suddenly you get StringIndexOutOfBoundsException. In the end, instead of diagnosing the HTTP problem, you diagnose your own string error. Math.min(body.length(), 200) is a tiny trick that makes your code noticeably calmer.
Mistake #4: assuming that if the body “looks like JSON,” then everything is successful.
Sometimes the server returns an error in JSON format — and that is normal. But if you do not look at the status, you may take an error for a successful response and start “happily” working with the wrong data. In the educational project this shows up quickly: you expect a list of books, but get a JSON error, and then you wonder why there is “no items field.”
Mistake #5: completely ignoring headers and then guessing “why won’t it parse?”
Even if we are not doing DTO mapping today, the Content-Type header already gives you a huge clue: did the server really return JSON, or did it return something else. When headers are ignored, debugging turns into shamanism: “well, it is some string… probably JSON… somehow it does not work…” One Content-Type in the output often puts everything back in its place.
GO TO FULL VERSION