At the close of the previous lesson one very concrete problem was left open: CoverSynchroniser takes 5.4 seconds to download five covers, and practically all of that time is network waiting with the CPU idle. A thousand covers would be almost twenty minutes. The downloads are independent of one another, so doing them in parallel would cut the time by nearly the parallelism factor — but with HttpURLConnection you would have to set up the pool, the tasks and the collection of results by hand.

This lesson solves that, and along the way everything else that was flagged as a defect of the old API. java.net.http, added in Java 11, is a modern HTTP client: immutable, with fluent builders, with genuine time limits, with HTTP/2 and multiplexing, with WebSocket, and with native asynchrony.

That last word is what makes this the module's closing lesson. Because sendAsync returns neither a response nor a Future: it returns a CompletableFuture<HttpResponse<String>>. Everything you learned in 08-07 —thenApply, thenCompose, thenCombine, allOf, exceptionally, orTimeout— applies here as it is, with no adaptation. It is no coincidence: CompletableFuture was designed above all with the network in mind, and this is the place where it really pays off.

By the end, BiblioTech will query the metadata of several ISBNs in parallel and compose a report without blocking a single thread. And module 9 will be complete.

Contents

  1. The three pieces and their immutable design
  2. Creating the client: HttpClient.newBuilder
  3. Why you create one and reuse it
  4. Building the request: HttpRequest
  5. The BodyPublishers: sending a body
  6. Receiving: HttpResponse<T> and the BodyHandlers
  7. Synchronous sending: send
  8. Asynchronous sending: sendAsync
  9. Composing asynchronous chains
  10. Several requests in parallel with allOf
  11. Error handling: exception versus status code
  12. HttpURLConnection versus HttpClient
  13. HTTP/2 and multiplexing
  14. WebSocket
  15. Sending and receiving JSON
  16. Good practice for calling external services
  17. BiblioTech: the asynchronous catalogue enricher
  18. Common Mistakes and Tips
  19. Exercises

  1. The three pieces and their immutable design

The API has exactly three main classes, with clean responsibilities:

graph LR
    A["HttpClient<br/>WHO makes the requests<br/>created ONCE"] --> B["send / sendAsync"]
    C["HttpRequest<br/>WHAT is asked for<br/>one per request"] --> B
    B --> D["HttpResponse&lt;T&gt;<br/>WHAT came back<br/>status, headers, body"]
    E["BodyPublisher<br/>how the body is SENT"] --> C
    F["BodyHandler&lt;T&gt;<br/>how the body is READ"] --> B
Class Role Lifecycle
HttpClient Who makes the requests. Holds configuration and the connection pool One per application, reused
HttpRequest What is asked for: URI, method, headers, body One per request
HttpResponse<T> What came back: status, headers, a body of type T One per response

All three are immutable. This is not a cosmetic detail; it has three important practical consequences:

  1. They are thread-safe. One HttpClient can be used from a hundred threads at once with no synchronisation. Compare it with HttpURLConnection, a mutable object with states that failed if you configured it out of order.
  2. An HttpRequest can be reused and sent many times.
  3. There is no configuration by side effect. No more setDoOutput(true) changing the method without saying so.

They are built with fluent builders (the builder pattern, which you will meet formally in 12-02):

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("http://localhost:8080/v1/books/978-0000000001"))
        .header("Accept", "application/json")
        .timeout(Duration.ofSeconds(10))
        .GET()
        .build();

HttpResponse<String> response = client.send(request,
        HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

System.out.println(response.statusCode());
System.out.println(response.body());

Compare those twelve lines with the thirty of the previous lesson, with their cast, their disconnect() in a finally and their distinction between the normal and the error stream. And everything important is there: time limits, headers, explicit charset.

About Duration. java.time.Duration is the class this API requires for time limits. Here we use it only as what it is in this context —a quantity of time, built with Duration.ofSeconds(5) or Duration.ofMillis(500)— and go no further. The complete java.time API is 10-05.

About HttpResponse<String>. That <String> is not a generic you have to define: it states the type of the response body, and it is determined by the BodyHandler you pass. With BodyHandlers.ofString() you get HttpResponse<String>; with ofInputStream(), HttpResponse<InputStream>; with ofFile(path), HttpResponse<Path>. Defining your own generics is 10-01; here they only have to be read.

  1. Creating the client: HttpClient.newBuilder

import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.Executors;

HttpClient client = HttpClient.newBuilder()

        // Protocol version. HTTP_2 is the default and it negotiates:
        // if the server does not support it, it falls back to HTTP/1.1 by itself.
        .version(HttpClient.Version.HTTP_2)

        // Time limit for ESTABLISHING the connection. It belongs to the client
        // because it affects all of its requests.
        .connectTimeout(Duration.ofSeconds(5))

        // Redirect policy.
        .followRedirects(HttpClient.Redirect.NORMAL)

        // Executor for the asynchronous operations. If not stated,
        // it uses an internal one. Passing yours gives control and thread names.
        .executor(Executors.newFixedThreadPool(8))

        // Proxy, if the network demands one.
        .proxy(ProxySelector.of(new InetSocketAddress("proxy.nexussoftware.com", 3128)))

        // Basic authentication, for services that use it.
        .authenticator(new Authenticator() {
            @Override
            protected PasswordAuthentication getPasswordAuthentication() {
                return new PasswordAuthentication("bibliotech",
                        "password".toCharArray());
            }
        })

        .build();

Or the minimal version, with all the defaults:

HttpClient client = HttpClient.newHttpClient();

The options that matter

Option Values Comment
version HTTP_1_1, HTTP_2 HTTP_2 by default, with automatic negotiation
connectTimeout Duration Always set it. Without it, infinite
followRedirects NEVER, ALWAYS, NORMAL NEVER by default — careful, unlike the old API
executor Executor For sendAsync. Without it, an internal one
proxy ProxySelector ProxySelector.getDefault() respects the system variables
authenticator Authenticator For basic and digest authentication only
cookieHandler CookieHandler Cookie handling, disabled by default
sslContext SSLContext For internal certificates (12-07)
priority 1-256 Stream priority in HTTP/2

Three warnings:

followRedirects is NEVER by default. HttpURLConnection followed redirects automatically; HttpClient does not. If your migrated code stops working with a 301, this is why. NORMAL is the sensible value: it follows redirects except from HTTPS to HTTP, which would be a security downgrade.

The authenticator only serves basic and digest authentication. Token authentication —the usual thing today— is done with a header:

.header("Authorization", "Bearer " + token)

About the executor. The internal one is an unbounded cached pool. Passing yours, with threads named as you learned in 08-02, makes thread dumps and logs worth something:

.executor(Executors.newFixedThreadPool(8, r -> {
    Thread t = new Thread(r, "bibliotech-http-" + counter.getAndIncrement());
    t.setDaemon(true);
    return t;
}))

  1. Why you create one and reuse it

This is the rule with the greatest impact on performance, and the one most often broken.

// WRONG. A new client per request.
for (String isbn : isbns) {
    HttpClient client = HttpClient.newHttpClient();       // <-- here is the problem
    HttpResponse<String> r = client.send(requestFor(isbn), ofString());
}

// RIGHT. One, created at startup, reused always.
private static final HttpClient CLIENT = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .build();

for (String isbn : isbns) {
    HttpResponse<String> r = CLIENT.send(requestFor(isbn), ofString());
}

What an HttpClient holds inside

Resource Why reusing it matters
Connection pool Every new connection costs a three-way handshake: a full round trip (09-01)
TLS sessions A TLS handshake costs one or two more round trips and asymmetric cryptography, which is expensive
Thread pool Creating and destroying one per request is pure waste
HTTP/2 connections A single connection serves many requests at once (section 13)

The numbers make it plain. Against a service 50 ms away:

New client per request Reused client
Establishing TCP 50 ms 50 ms only the first time
TLS handshake (HTTPS) 100 ms 100 ms only the first time
The request itself 50 ms 50 ms
Total per request 200 ms 50 ms (after the first)

Four times slower, and over HTTPS worse still. Besides, every new client creates its own thread pool: creating hundreds of clients leaks threads and memory until the application falls over.

About closing the client. Up to Java 20, HttpClient did not implement AutoCloseable: there was no way of closing it explicitly and you relied on the garbage collector. Since Java 21 it does, with close(), shutdown() and shutdownNow(), following the same two-phase shutdown model as ExecutorService that you know from 08-05. If you work with Java 17, simply create the client as a static final field and do not worry; if you are on 21 or later, close it in the application's graceful shutdown.

  1. Building the request: HttpRequest

import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;

HttpRequest request = HttpRequest.newBuilder()

        // The URI is mandatory. Notice it is URI, not URL: the modern API
        // uses the right class, without the equals() that does DNS (09-05).
        .uri(URI.create("https://api.nexussoftware.com/v1/books/978-0000000001"))

        // Headers. header() adds; setHeader() replaces if it already exists.
        .header("Accept", "application/json")
        .header("User-Agent", "BiblioTech/1.0")
        .header("Authorization", "Bearer " + token)

        // Several at once: name, value pairs.
        .headers("Accept-Language", "en-GB", "X-Origin", "bibliotech")

        // TOTAL TIME LIMIT of the request. This did NOT exist in
        // HttpURLConnection, which only had a per-operation limit.
        .timeout(Duration.ofSeconds(10))

        // A specific version for this request, if needed.
        .version(HttpClient.Version.HTTP_1_1)

        // The method. One of these, at the end.
        .GET()

        .build();

The methods

.GET()                                          // no body
.DELETE()                                       // no body
.POST(HttpRequest.BodyPublishers.ofString(json))
.PUT(HttpRequest.BodyPublishers.ofString(json))
.method("PATCH", HttpRequest.BodyPublishers.ofString(json))   // any other one

PATCH has no method of its own because it reached the standard later; you use method(name, publisher), which works for any method, including custom ones.

The total time limit: the key improvement

This is one of the most important differences from the old API.

HttpURLConnection HttpClient
Connection limit setConnectTimeout .connectTimeout() on the client
Read limit setReadTimeout, per operation
Total limit Does not exist .timeout() on the request

The real problem it solves: a server that sends one byte every nine seconds keeps a request with setReadTimeout(10_000) alive indefinitely, because the deadline resets with every byte. With .timeout(Duration.ofSeconds(10)), after ten seconds the request ends, whatever happens, with an HttpTimeoutException. That behaviour —sometimes called a slow server attack— was impossible to bound with the old API.

Reusing requests

Being immutable, an HttpRequest can be sent many times, and you can also start from a template:

// A template with the common parts. Built once.
HttpRequest.Builder template = HttpRequest.newBuilder()
        .header("Accept", "application/json")
        .header("User-Agent", "BiblioTech/1.0")
        .timeout(Duration.ofSeconds(10));

// And for each ISBN, only the URI changes.
// copy() clones the builder: without it, we would modify the template.
HttpRequest r1 = template.copy()
        .uri(URI.create(base + "/978-0000000001")).GET().build();
HttpRequest r2 = template.copy()
        .uri(URI.create(base + "/978-0000000002")).GET().build();

The copy() is important: without it, template.uri(...) would modify the template and the next request would inherit the previous URI.

  1. The BodyPublishers: sending a body

A BodyPublisher describes where the body's bytes come from.

Method Sends Typical use
ofString(s) Text (UTF-8 by default) JSON, XML, forms
ofString(s, charset) Text with an explicit charset When it is not UTF-8
ofByteArray(bytes) A byte array Small binary data
ofFile(path) The content of a file, without loading it into memory Uploading large files
ofInputStream(sup) Whatever an InputStream produces Generated content
noBody() Nothing POST with no body
fromPublisher(p) A Flow.Publisher<ByteBuffer> Reactive, advanced
import java.net.http.HttpRequest.BodyPublishers;

// JSON
HttpRequest r = HttpRequest.newBuilder()
        .uri(URI.create(base + "/v1/loans"))
        .header("Content-Type", "application/json; charset=utf-8")
        .POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8))
        .build();

// Uploading a file WITHOUT loading it into memory: it is read as it is sent.
// With HttpURLConnection the streaming had to be done by hand.
HttpRequest upload = HttpRequest.newBuilder()
        .uri(URI.create(base + "/v1/catalog/import"))
        .header("Content-Type", "text/csv; charset=utf-8")
        .POST(BodyPublishers.ofFile(Path.of("catalog.csv")))
        .build();

// A form: here URLEncoder IS the right tool (09-05).
String form = "isbn=" + URLEncoder.encode(isbn, UTF_8)
        + "&employee=" + URLEncoder.encode(employee, UTF_8);
HttpRequest formRequest = HttpRequest.newBuilder()
        .uri(URI.create(base + "/v1/loans"))
        .header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
        .POST(BodyPublishers.ofString(form))
        .build();

Two things the API does for you that used to be manual: it computes the Content-Length (remember the bytes-versus-characters mess of 09-05) and ofFile streams without loading into memory, which lets you upload a one-gigabyte file with no trouble.

  1. Receiving: HttpResponse<T> and the BodyHandlers

A BodyHandler<T> decides what the response body turns into, and with it the type T of the HttpResponse<T>.

Handler Resulting type Use
ofString() HttpResponse<String> JSON, HTML, text
ofString(charset) HttpResponse<String> With an explicit charset
ofByteArray() HttpResponse<byte[]> Small binary
ofFile(path) HttpResponse<Path> Direct download to disk
ofInputStream() HttpResponse<InputStream> Processing without loading into memory
ofLines() HttpResponse<Stream<String>> Line by line (uses Streams: 10-04)
discarding() HttpResponse<Void> Discard the body but consume it
replacing(v) HttpResponse<T> Discard and return a fixed value
ofByteArrayConsumer(c) HttpResponse<Void> Process in chunks
import java.net.http.HttpResponse.BodyHandlers;

// Text. The explicit charset, as always.
HttpResponse<String> text = client.send(request,
        BodyHandlers.ofString(StandardCharsets.UTF_8));

// Straight to a file. Goodbye to the copy loop of 09-05.
HttpResponse<Path> file = client.send(request,
        BodyHandlers.ofFile(Path.of("covers", isbn + ".jpg")));
System.out.println("Saved in " + file.body());

// As a stream, to process without loading it whole.
HttpResponse<InputStream> stream = client.send(request,
        BodyHandlers.ofInputStream());
try (InputStream input = stream.body()) {
    // ... module 7's InputStream, once again ...
}

// Only the status code is of interest (like a HEAD).
HttpResponse<Void> statusOnly = client.send(request, BodyHandlers.discarding());

ofFile deserves a moment of attention. In 09-05 you wrote a copy loop with a buffer, a limit check, a temporary file and an atomic move. With ofFile, downloading to disk is one call. (The temporary file and the atomic move are still yours if you want that guarantee, and they are still worth it.)

What HttpResponse<T> offers

HttpResponse<String> r = client.send(request, BodyHandlers.ofString(UTF_8));

int code = r.statusCode();                // 200
String body = r.body();                   // the body, of type T
HttpHeaders headers = r.headers();        // the headers
URI uri = r.uri();                        // the FINAL URI (after redirects)
HttpClient.Version version = r.version(); // HTTP_2 or HTTP_1_1
HttpRequest original = r.request();       // the request that produced it

// The headers, with a decent API:
Optional<String> type = r.headers().firstValue("Content-Type");
List<String> all = r.headers().allValues("Set-Cookie");
OptionalLong length = r.headers().firstValueAsLong("Content-Length");

Two improvements over the old API: uri() returns the final URI after the redirects, which is information you previously had to track by hand; and headers() returns an HttpHeaders with useful methods instead of that Map<String, List<String>> with the null-keyed entry.

A note on Optional. firstValue returns Optional<String> because a header may be absent. Here we use it only with orElse(...) or isPresent(); Optional in depth, along with Streams, is 10-04.

And the most important thing, which links back to the previous lesson: HttpResponse does not distinguish between the normal and the error stream. With a 404 or a 500 you get the error body in body() like any other. The getInputStream versus getErrorStream distinction is gone.

  1. Synchronous sending: send

HttpResponse<String> response = client.send(request,
        BodyHandlers.ofString(StandardCharsets.UTF_8));

It blocks the thread until the complete response arrives. It throws:

Exception When
IOException Network failure: connection refused, unknown host, broken connection
HttpTimeoutException The request's .timeout() expired (a subclass of IOException)
HttpConnectTimeoutException The client's .connectTimeout() expired
InterruptedException The thread was interrupted while waiting

Notice InterruptedException: send is interruptible. Unlike a blocking socket.read(), which ignores interruptions (09-03), here the cancellation protocol of 08-02 works.

try {
    HttpResponse<String> r = client.send(request, BodyHandlers.ofString(UTF_8));

    // MANDATORY: check the code. A 500 does NOT throw.
    if (r.statusCode() != 200) {
        throw new BiblioTechException("The service replied " + r.statusCode()
                + ": " + trim(r.body()));
    }
    process(r.body());

} catch (HttpTimeoutException e) {
    // Transient: worth a retry with increasing backoff.
    throw new BiblioTechException("The service is not replying in time", e);

} catch (IOException e) {
    throw new BiblioTechException("Network error querying the service", e);

} catch (InterruptedException e) {
    Thread.currentThread().interrupt();     // 08-02: ALWAYS restore the flag
    throw new BiblioTechException("Query interrupted", e);
}

  1. Asynchronous sending: sendAsync

And here is where module 8 and module 9 meet.

CompletableFuture<HttpResponse<String>> future = client.sendAsync(request,
        BodyHandlers.ofString(StandardCharsets.UTF_8));

sendAsync returns immediately, blocking nothing, with a CompletableFuture<HttpResponse<String>>. That type says exactly what it is: a future that, when it completes, will hold an HTTP response whose body is a String.

And from there, all of 08-07 applies without changing a comma:

client.sendAsync(request, BodyHandlers.ofString(UTF_8))
        .thenApply(HttpResponse::body)              // extract the body
        .thenApply(this::parseMetadata)             // turn it into an object
        .thenAccept(m -> System.out.println(m.title()))
        .exceptionally(e -> {
            LOG.warning("Failure: " + e.getMessage());
            return null;
        });
// The current thread carries on working. It has waited for nothing.
sequenceDiagram
    participant M as Main thread
    participant C as HttpClient
    participant E as Executor
    participant S as External service
    M->>C: sendAsync(request, ofString)
    C-->>M: CompletableFuture (empty, instantly)
    Note over M: The main thread CARRIES ON. It does not wait.
    C->>S: GET /v1/books/978-0000000001
    Note over S: processes (200 ms)
    S-->>C: 200 OK {...}
    C->>E: completes the future
    E->>E: thenApply(body)
    E->>E: thenApply(parse)
    E->>E: thenAccept(show)
    Note over M,E: The result is processed on an executor thread

Which thread each stage runs on

The same as in 08-07: stages with no Async suffix may run on the thread that completed the previous one —here, an internal HttpClient thread—, and the ones carrying Async use the executor.

The practical rule and the reason behind it: cheap transformations with no suffix; expensive or blocking work with ...Async and your own executor. If you do a heavy operation in a suffix-free thenApply, you run it on an internal thread of the HTTP client and you are throttling its ability to serve other responses.

// WRONG: writing to disk on an internal HttpClient thread.
client.sendAsync(request, BodyHandlers.ofString(UTF_8))
        .thenApply(r -> { writeToDisk(r.body()); return r; });

// RIGHT: the expensive work goes to our executor.
client.sendAsync(request, BodyHandlers.ofString(UTF_8))
        .thenApplyAsync(r -> { writeToDisk(r.body()); return r; }, ioExecutor);

  1. Composing asynchronous chains

The operators of 08-07, applied to HTTP.

thenApply: transforming the result

CompletableFuture<Metadata> future =
        client.sendAsync(requestFor(isbn), BodyHandlers.ofString(UTF_8))
                .thenApply(r -> {
                    // The code is checked HERE, inside the chain.
                    if (r.statusCode() == 404) {
                        return null;
                    }
                    if (r.statusCode() != 200) {
                        // Throwing inside the chain makes it fail,
                        // and the failure reaches the exceptionally.
                        throw new CompletionException(
                                new BiblioTechException("HTTP " + r.statusCode()));
                    }
                    return r.body();
                })
                .thenApply(this::parseMetadata);

thenCompose: chaining another request

When the result of one request determines the next. The distinction from 08-07 still holds: thenApply when the function returns a value; thenCompose when it returns another CompletableFuture.

// First the metadata, and with the URL it carries, the cover.
CompletableFuture<Path> future =
        client.sendAsync(metadataRequest(isbn), BodyHandlers.ofString(UTF_8))
                .thenApply(r -> parseMetadata(r.body()))
                .thenCompose(m -> {
                    // It returns a CompletableFuture -> thenCompose, not thenApply.
                    // With thenApply we would get a
                    // CompletableFuture<CompletableFuture<HttpResponse<Path>>>.
                    HttpRequest r = HttpRequest.newBuilder()
                            .uri(URI.create(m.coverUrl()))
                            .timeout(Duration.ofSeconds(30))
                            .GET().build();
                    return client.sendAsync(r,
                            BodyHandlers.ofFile(Path.of("covers", isbn + ".jpg")));
                })
                .thenApply(HttpResponse::body);

Two dependent requests, chained, without blocking a single thread. With HttpURLConnection this would be two try blocks with two waits.

thenCombine: joining two independent ones

// Two different services, queried IN PARALLEL.
CompletableFuture<String> metadata =
        client.sendAsync(metadataRequest(isbn), BodyHandlers.ofString(UTF_8))
                .thenApply(HttpResponse::body);

CompletableFuture<String> reviews =
        client.sendAsync(reviewsRequest(isbn), BodyHandlers.ofString(UTF_8))
                .thenApply(HttpResponse::body);

// The total time is that of the SLOWEST, not the sum.
CompletableFuture<String> card = metadata.thenCombine(reviews,
        (m, v) -> composeCard(m, v));

orTimeout: a deadline on the whole chain

client.sendAsync(request, BodyHandlers.ofString(UTF_8))
        .thenApply(this::process)
        .orTimeout(15, TimeUnit.SECONDS)        // deadline for the WHOLE chain
        .exceptionally(e -> {
            if (e.getCause() instanceof TimeoutException) {
                LOG.warning("The complete chain exceeded 15 s");
            }
            return defaultResponse();
        });

With the warning from 08-07 that still applies: orTimeout does not cancel the underlying work. The HTTP request carries on; only the future is completed with an error. To cancel for real you have to call cancel(true) on the future returned by sendAsync, which does abort the request.

  1. Several requests in parallel with allOf

The case that solves the open problem of 09-05.

package com.nexussoftware.bibliotech.network;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;

/** Querying several ISBNs in parallel with allOf. */
public class ParallelQuery {

    private final HttpClient client;
    private final String base;

    public ParallelQuery(HttpClient client, String base) {
        this.client = client;
        this.base = base;
    }

    public List<String> queryAll(List<String> isbns) {
        List<CompletableFuture<String>> futures = new ArrayList<>();

        // 1. Launch ALL the requests. sendAsync returns instantly,
        //    so this loop finishes in microseconds and the N requests
        //    are all in flight simultaneously.
        for (String isbn : isbns) {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(base + "/v1/books/" + isbn))
                    .header("Accept", "application/json")
                    .timeout(Duration.ofSeconds(10))
                    .GET()
                    .build();

            CompletableFuture<String> future =
                    client.sendAsync(request, BodyHandlers.ofString(StandardCharsets.UTF_8))
                            .thenApply(r -> r.statusCode() == 200
                                    ? r.body()
                                    : "ERROR " + r.statusCode() + " on " + isbn)
                            // SHIELD EVERY FUTURE BEFORE AGGREGATING IT.
                            // Without this, a single failure fails the whole
                            // allOf and we lose the N-1 good responses.
                            // It is the classic trap of 08-07.
                            .exceptionally(e -> "FAILURE on " + isbn + ": "
                                    + e.getCause().getMessage());

            futures.add(future);
        }

        // 2. allOf completes when ALL of them finish.
        CompletableFuture<Void> all = CompletableFuture.allOf(
                futures.toArray(new CompletableFuture[0]));

        // 3. Collect. join() here is safe because allOf has already
        //    guaranteed that they are all complete: it blocks nothing.
        return all.thenApply(v -> {
            List<String> results = new ArrayList<>(futures.size());
            for (CompletableFuture<String> f : futures) {
                results.add(f.join());
            }
            return results;
        }).join();      // the only real block, and it is deliberate
    }
}

The three points of the pattern, all learned in 08-07:

  1. Launch them all before waiting for any. The sendAsync loop finishes in microseconds with N requests in flight.
  2. Shield every future with its own exceptionally before aggregating it. Without this, a single failure fails the whole allOf and you lose every good response. It is this API's most expensive mistake.
  3. join() after the allOf is safe, because all the futures are already complete.

The measured difference

Twenty ISBNs against a service taking 200 ms per query:

Strategy Time How
Sequential (send in a loop) 4,000 ms 20 × 200 ms
Parallel (sendAsync + allOf) ~250 ms All at once, plus overhead
Improvement 16×

And the same network traffic, exactly as the estimator of exercise 3 in 09-01 demonstrated: what changes is not the volume, it is the overlapping of the waits.

Careful with runaway parallelism. Firing a thousand sendAsync calls at once creates a thousand simultaneous requests and will probably earn you a 429 or an IP block. In production you have to bound it: a Semaphore like the one from 08-05, or processing in batches. We will apply it in BiblioTech's enricher.

  1. Error handling: exception versus status code

The distinction that causes the most bugs, and it is worth making completely clear here.

Situation Exception? How it is detected
Unknown host Yes IOException (UnresolvedAddressException as the cause)
Connection refused Yes IOException / ConnectException
Connection time limit Yes HttpConnectTimeoutException
Request time limit Yes HttpTimeoutException
Connection broken halfway Yes IOException
TLS certificate failure Yes IOException with an SSLHandshakeException cause
404 Not Found NO statusCode() == 404
429 Too Many Requests NO statusCode() == 429
500 Internal Server Error NO statusCode() == 500
503 Service Unavailable NO statusCode() == 503

The rule, in one sentence: there is an exception when no HTTP response could be obtained. If there is a response, the network succeeded, even if the code is 500.

The classic mistake, written out so you recognise it:

// BROKEN CODE. Very common.
HttpResponse<String> r = client.send(request, BodyHandlers.ofString(UTF_8));
Metadata m = parse(r.body());            // <-- if it was a 500, body() is
                                         //     the server's error page

With a 500, body() holds nginx's error HTML or the service's error JSON. parse() will receive rubbish and fail incomprehensibly, or —worse— return absurd data that looks valid.

In an asynchronous chain, the check goes inside:

client.sendAsync(request, BodyHandlers.ofString(UTF_8))
        .thenApply(r -> {
            if (r.statusCode() == 404) {
                return null;        // "it does not exist" is a result, not an error
            }
            if (r.statusCode() / 100 == 5) {
                // Throwing inside a stage fails the future,
                // and the failure propagates to the first handler.
                throw new CompletionException(
                        new BiblioTechException("Server failure: " + r.statusCode()));
            }
            if (r.statusCode() != 200) {
                throw new CompletionException(
                        new BiblioTechException("Unexpected response: " + r.statusCode()));
            }
            return r.body();
        })
        .exceptionally(e -> {
            // CAREFUL: the cause arrives WRAPPED in CompletionException (08-07).
            Throwable cause = e.getCause() != null ? e.getCause() : e;
            LOG.warning("Query failed: " + cause.getMessage());
            return null;
        });

  1. HttpURLConnection versus HttpClient

Criterion HttpURLConnection (1996) HttpClient (Java 11)
Lines for a simple GET ~30 with resource handling ~8
Mutability A mutable object with states Immutable
Thread-safe No Yes
Fluent builders No Yes
Total time limit Does not exist Yes, .timeout()
Asynchrony No sendAsyncCompletableFuture
HTTP/2 No Yes, by default
Multiplexing No Yes, with HTTP/2
Connection reuse Yes, but opaque and fragile Yes, a managed pool
WebSocket No Yes
Error stream getErrorStream() separately Just one: body()
http→https redirects It does not follow them Yes, with NORMAL
Body to a file A hand-written copy loop BodyHandlers.ofFile
Uploading a file without memory setFixedLengthStreamingMode by hand BodyPublishers.ofFile
Response headers A Map with an odd null key HttpHeaders with methods
Final URI after a redirect It has to be tracked response.uri()
Ease of testing Very low Medium (a substitutable interface)
Available since Java 1.0 Java 11

The only reason to use the old one is having to compile for Java 8 or earlier, or maintaining code that already uses it. For everything else, HttpClient.

  1. HTTP/2 and multiplexing

HttpClient speaks HTTP/2 by default and negotiates automatically: if the server does not support it, it falls back to HTTP/1.1 by itself.

The main improvement is multiplexing. In HTTP/1.1, one TCP connection serves one request at a time: to make six in parallel you need six connections, with their six three-way handshakes and their six TLS handshakes. HTTP/2 splits the connection into independent streams that travel interleaved, so that a single connection serves dozens of simultaneous requests.

HTTP/1.1, six requests in parallel:
  connection 1: [handshake][TLS][request A............]
  connection 2: [handshake][TLS][request B............]
  ... six connections, six setups ...

HTTP/2, six requests in parallel:
  connection 1: [handshake][TLS][A|B|C|A|D|B|E|C|F|...]
  ... ONE connection, ONE setup, interleaved streams ...

Consequences for you:

  • An allOf with twenty requests to the same host uses a single connection instead of twenty. Fewer setups, fewer TLS handshakes, fewer resources on the server.
  • The headers are compressed (HPACK), which matters when you send a long token on every request.
  • Head-of-line blocking still exists at the TCP level: a lost packet delays every stream on that connection. It is the problem HTTP/3 solves by moving to UDP, as you saw in 09-04.
// Check which version was actually negotiated.
HttpResponse<String> r = client.send(request, BodyHandlers.ofString(UTF_8));
System.out.println("Negotiated version: " + r.version());   // HTTP_2 or HTTP_1_1

  1. WebSocket

The same package includes a WebSocket client, the bidirectional, persistent communication protocol over HTTP. With HTTP the client asks and the server replies; with WebSocket either side can send at any moment, which is useful for notifications, chats and live data.

package com.nexussoftware.bibliotech.network;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.logging.Logger;

/**
 * A minimal WebSocket example: BiblioTech would receive notices from the
 * server in real time ("the book you were waiting for is available") without polling.
 */
public class WebSocketNotices {

    private static final Logger LOG = Logger.getLogger(WebSocketNotices.class.getName());

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // The Listener receives the events. Notice the request(1) at the end
        // of each method: WebSocket uses BACKPRESSURE, and the next message
        // has to be asked for explicitly.
        WebSocket.Listener listener = new WebSocket.Listener() {

            @Override
            public void onOpen(WebSocket ws) {
                LOG.info("WebSocket connection opened");
                ws.request(1);      // we ask for the first message
            }

            @Override
            public CompletionStage<?> onText(WebSocket ws, CharSequence data,
                                             boolean last) {
                System.out.println("NOTICE: " + data);
                ws.request(1);      // and the next one
                return null;
            }

            @Override
            public CompletionStage<?> onClose(WebSocket ws, int code, String reason) {
                LOG.info("WebSocket closed: " + code + " " + reason);
                return null;
            }

            @Override
            public void onError(WebSocket ws, Throwable error) {
                LOG.warning("WebSocket error: " + error.getMessage());
            }
        };

        // buildAsync returns a CompletableFuture<WebSocket>: total
        // consistency with the rest of the API.
        WebSocket ws = client.newWebSocketBuilder()
                .buildAsync(URI.create("ws://localhost:8080/notices"), listener)
                .join();

        ws.sendText("SUBSCRIBE 978-0000000001", true);

        Thread.sleep(30_000);       // we listen for 30 seconds

        ws.sendClose(WebSocket.NORMAL_CLOSURE, "end").join();
    }
}

It is mentioned for completeness: it is the standard library's answer for when periodic polling is not enough. Using it in depth is outside the scope of this course.

  1. Sending and receiving JSON

JSON is the interchange format of practically every current API. And here it is time to be honest about what can and cannot be done with the bare JDK.

Building the body

// BY HAND. It works for simple cases, and IT MUST BE ESCAPED.
String json = "{"
        + "\"isbn\":\"" + escape(isbn) + "\","
        + "\"employee\":\"" + escape(employee) + "\","
        + "\"days\":" + days
        + "}";

/**
 * Minimal JSON escaping. The characters that BREAK the document
 * if they are not escaped are: the double quote, the backslash
 * and the control characters.
 */
static String escape(String text) {
    StringBuilder sb = new StringBuilder(text.length() + 16);
    for (int i = 0; i < text.length(); i++) {
        char c = text.charAt(i);
        switch (c) {
            case '"'  -> sb.append("\\\"");
            case '\\' -> sb.append("\\\\");
            case '\n' -> sb.append("\\n");
            case '\r' -> sb.append("\\r");
            case '\t' -> sb.append("\\t");
            default -> {
                if (c < 0x20) {
                    sb.append(String.format("\\u%04x", (int) c));
                } else {
                    sb.append(c);
                }
            }
        }
    }
    return sb.toString();
}

Extracting a field from the response

/**
 * Extracts "field":"value" from a JSON document BY SUBSTRING SEARCH.
 *
 * THIS IS A TEACHING STOPGAP AND IT HAS TO BE SAID PLAINLY.
 *
 * It works with flat, simple responses like this service's,
 * and IT BREAKS with:
 *   - values containing the searched substring
 *   - escaped quotes inside a value ("Effective \"Java\"")
 *   - nested objects or arrays
 *   - a field with the same name inside an inner object
 *   - null, numeric or boolean values where text is expected
 *   - different spacing around the colon
 *
 * DOING IT PROPERLY REQUIRES A JSON LIBRARY, AND THAT IS 11-07 (JACKSON),
 * where one line -mapper.readValue(json, Metadata.class)- replaces
 * all of this and on top of that converts straight into the record. Do not
 * take this code to production.
 */
static String textField(String json, String field) {
    String mark = "\"" + field + "\"";
    int i = json.indexOf(mark);
    if (i < 0) {
        return null;
    }
    int colon = json.indexOf(':', i + mark.length());
    if (colon < 0) {
        return null;
    }
    int open = json.indexOf('"', colon);
    if (open < 0) {
        return null;
    }
    // We look for the closing quote, skipping escaped ones.
    int j = open + 1;
    StringBuilder value = new StringBuilder();
    while (j < json.length()) {
        char c = json.charAt(j);
        if (c == '\\' && j + 1 < json.length()) {
            value.append(json.charAt(j + 1));
            j += 2;
            continue;
        }
        if (c == '"') {
            return value.toString();
        }
        value.append(c);
        j++;
    }
    return null;
}

It is important to understand the message. It is not that this code is bad through carelessness: it is that parsing JSON correctly is a solved problem you should not be solving. The JDK ships no JSON parser, so in this module, which sticks to the standard library, the honest option is a bounded, clearly flagged stopgap. In 11-07 you will see Jackson, and mapper.readValue(json, Metadata.class) will replace all these lines, returning the record already built.

  1. Good practice for calling external services

An external service is the part of your system that you do not control. These practices assume it will fail.

  1. Time limits, always and both of them

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))      // establishing
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .timeout(Duration.ofSeconds(10))            // total for the request
        .build();

Without them, a slow service propagates through your system until it exhausts the threads. It is the number-one cause of cascading outages.

  1. Retry only what is transient

Situation Retry?
HttpTimeoutException Yes
ConnectException Yes, a few times
429, 502, 503, 504 Yes, respecting Retry-After if it comes
UnresolvedAddressException No
4xx (except 429) No
500 With caution: it may be deterministic

With increasing, randomised backoff, exactly as in 09-05: 200 ms, 400, 800... plus 20 % random variation to stop a hundred clients retrying in lockstep and taking down again the service that was recovering.

  1. Do not retry a non-idempotent POST

If a POST that registers a loan runs out of time, you do not know whether the server processed it. Retrying may create two loans. The professional solution is the idempotency key:

// The client generates a UNIQUE identifier per logical operation
// -not per attempt- and sends it. The server stores the keys it has
// already seen and returns the previous result instead of repeating the
// operation. With this, retrying IS safe.
String key = UUID.randomUUID().toString();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(base + "/v1/loans"))
        .header("Idempotency-Key", key)         // the SAME on every retry
        .header("Content-Type", "application/json")
        .POST(BodyPublishers.ofString(json))
        .build();

  1. Circuit breaker, in one sentence

If a service has failed twenty times in a row, going on calling it only consumes your threads and delays your users: a circuit breaker stops trying for a while, returns the error immediately and every so often checks whether it has recovered. It is implemented by hand or with libraries such as Resilience4j; the resilience patterns are covered in 12-07.

  1. Never log credentials

// WRONG: the token ends up in the log file, and from there in the
// backup, in the log aggregation system and in any support
// screenshot.
LOG.info("Request: " + request.headers());

// RIGHT: only what may be logged.
LOG.info(() -> "GET " + request.uri().getPath()
        + " -> " + response.statusCode()
        + " (" + ms + " ms)");

It picks up what you learned in 06-07: sensitive data does not go into the log. And there is a rule that gets forgotten: a URL with a token in the query string is sensitive too. Logging the full URI leaks the token just as logging the header does. That is why the good example logs only getPath().

The list of what is never logged: tokens, API keys, passwords, session cookies, Authorization headers, card numbers and personal data.

  1. An identifying User-Agent

.header("User-Agent", "BiblioTech/1.0 (+https://nexussoftware.com/bibliotech)")

  1. TLS and certificate validation

Use https whenever the service offers it. Never disable certificate validation: it turns HTTPS into HTTP with extra steps and opens the door to a man-in-the-middle attack. If you have an internal certificate, configure an SSLContext with your trust store:

HttpClient client = HttpClient.newBuilder()
        .sslContext(contextWithOwnStore())           // NOT a TrustManager that accepts everything
        .build();

Network security is covered thoroughly in 12-07.

  1. BiblioTech: the asynchronous catalogue enricher

Everything together, and solving the problem 09-05 left open.

package com.nexussoftware.bibliotech.network;

import com.nexussoftware.bibliotech.domain.Material;
import com.nexussoftware.bibliotech.service.ConcurrentCatalog;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Enriches BiblioTech's catalogue with data from the external metadata
 * service, querying ALL the ISBNs in parallel and downloading
 * the covers without blocking a single thread.
 *
 * It solves the problem CoverSynchroniser (09-05) left open,
 * which was sequential and spent all its time waiting for the network.
 *
 * It applies: a reused HttpClient, sendAsync + thenCompose + allOf from 08-07,
 * a Semaphore from 08-05 to bound the parallelism, and LongAdder from 08-06
 * for the metrics.
 */
public class CatalogEnricher implements AutoCloseable {

    private static final Logger LOG =
            Logger.getLogger(CatalogEnricher.class.getName());

    private static final String USER_AGENT = "BiblioTech/1.0 (+https://nexussoftware.com)";
    private static final int MAX_SIMULTANEOUS = 8;
    private static final long MAX_COVER = 5L * 1024 * 1024;

    private final String base;
    private final Path coverDirectory;
    private final HttpClient client;
    private final ExecutorService executor;

    /**
     * Bounds the parallelism. Without it, a thousand ISBNs would produce a
     * thousand simultaneous requests and the service would return 429 or
     * block our IP. It is the Semaphore of 08-05, applied to the network.
     */
    private final Semaphore permits = new Semaphore(MAX_SIMULTANEOUS);

    // Metrics with no contention (08-06).
    private final LongAdder enriched = new LongAdder();
    private final LongAdder notFound = new LongAdder();
    private final LongAdder coversDownloaded = new LongAdder();
    private final LongAdder failed = new LongAdder();
    private final LongAdder coverBytes = new LongAdder();

    public CatalogEnricher(String base, Path coverDirectory) {
        this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
        this.coverDirectory = coverDirectory;

        // Our own executor with NAMED threads (08-02): in a thread dump
        // and in every log line you will know who is doing what.
        AtomicInteger n = new AtomicInteger(1);
        this.executor = Executors.newFixedThreadPool(MAX_SIMULTANEOUS, r -> {
            Thread t = new Thread(r, "bibliotech-http-" + n.getAndIncrement());
            t.setDaemon(true);
            return t;
        });

        // ONE client, created once and reused: connection pool,
        // reused TLS sessions and HTTP/2 multiplexing.
        this.client = HttpClient.newBuilder()
                .version(HttpClient.Version.HTTP_2)
                .connectTimeout(Duration.ofSeconds(5))
                .followRedirects(HttpClient.Redirect.NORMAL)
                .executor(executor)
                .build();
    }

    /** The enriched card of a material. */
    public record EnrichedCard(String isbn, String title, String author,
                               int pages, Path cover, String status) {
    }

    // =================================================================
    // Entry point
    // =================================================================

    public List<EnrichedCard> enrich(ConcurrentCatalog catalog) {
        List<Material> materials = catalog.all();
        List<String> isbns = new ArrayList<>(materials.size());
        for (Material m : materials) {
            isbns.add(m.getIsbn());
        }
        return enrichIsbns(isbns);
    }

    public List<EnrichedCard> enrichIsbns(List<String> isbns) {
        long start = System.currentTimeMillis();
        System.out.println("Enriching " + isbns.size()
                + " materials (up to " + MAX_SIMULTANEOUS + " at a time)...\n");

        try {
            Files.createDirectories(coverDirectory);
        } catch (Exception e) {
            LOG.log(Level.WARNING, "Could not create the cover directory", e);
        }

        // --- 1. Launch ALL the chains ---
        // sendAsync returns instantly, so this loop finishes in
        // microseconds with N chains under way.
        List<CompletableFuture<EnrichedCard>> futures =
                new ArrayList<>(isbns.size());
        for (String isbn : isbns) {
            futures.add(chainFor(isbn));
        }

        // --- 2. Wait for them all ---
        CompletableFuture<Void> all = CompletableFuture.allOf(
                futures.toArray(new CompletableFuture[0]));

        // --- 3. Collect ---
        List<EnrichedCard> cards = all.thenApply(v -> {
            List<EnrichedCard> list = new ArrayList<>(futures.size());
            for (CompletableFuture<EnrichedCard> f : futures) {
                // join() is safe: allOf has already guaranteed they are all complete.
                list.add(f.join());
            }
            return list;
        }).join();      // the only real block, deliberate

        report(System.currentTimeMillis() - start, isbns.size());
        return cards;
    }

    // =================================================================
    // The asynchronous chain of ONE material
    // =================================================================

    private CompletableFuture<EnrichedCard> chainFor(String isbn) {
        if (!validIsbn(isbn)) {
            failed.increment();
            return CompletableFuture.completedFuture(
                    new EnrichedCard(isbn, null, null, 0, null, "INVALID ISBN"));
        }

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(base + "/v1/books/" + isbn))
                .header("Accept", "application/json")
                .header("User-Agent", USER_AGENT)
                .timeout(Duration.ofSeconds(10))    // TOTAL limit, not per operation
                .GET()
                .build();

        // acquirePermit blocks if there are already 8 in flight. It is done BEFORE
        // the sendAsync so the semaphore limits real requests.
        acquirePermit();

        return client.sendAsync(request, BodyHandlers.ofString(StandardCharsets.UTF_8))

                // --- Stage 1: check the code and keep the body ---
                .thenApply(response -> {
                    releasePermit();

                    int code = response.statusCode();
                    LOG.fine(() -> "GET /v1/books/" + isbn + " -> " + code);

                    if (code == 404) {
                        return null;        // "I do not have it" is a result
                    }
                    if (code != 200) {
                        // Throwing here fails the future; the failure reaches
                        // the exceptionally at the end.
                        throw new CompletionException(
                                new java.io.IOException("HTTP " + code
                                        + " querying " + isbn));
                    }
                    return response.body();
                })

                // --- Stage 2: parse the JSON ---
                .thenApply(body -> {
                    if (body == null) {
                        notFound.increment();
                        return new EnrichedCard(isbn, null, null, 0, null,
                                "NOT FOUND");
                    }
                    String title = textField(body, "title");
                    String author = textField(body, "author");
                    String cover = textField(body, "cover");
                    int pages = intField(body, "pages");

                    if (title == null) {
                        throw new CompletionException(
                                new java.io.IOException("Response with no title"));
                    }
                    enriched.increment();
                    return new EnrichedCard(isbn, title,
                            author == null ? "(unknown)" : author,
                            pages,
                            cover == null ? null : Path.of(cover),      // placeholder
                            "OK");
                })

                // --- Stage 3: download the cover, if there is one ---
                // thenCompose because it returns ANOTHER CompletableFuture:
                // with thenApply we would have a future of a future (08-07).
                .thenCompose(card -> {
                    if (card.cover() == null) {
                        return CompletableFuture.completedFuture(card);
                    }
                    // The 'cover' field provisionally carries the URL.
                    String url = card.cover().toString();
                    return downloadCover(url, isbn)
                            .thenApply(path -> new EnrichedCard(
                                    card.isbn(), card.title(), card.author(),
                                    card.pages(), path,
                                    path == null ? "OK (no cover)" : "OK"));
                })

                // --- Deadline for the COMPLETE chain ---
                .orTimeout(30, TimeUnit.SECONDS)

                // --- Shielding: MANDATORY before aggregating into the allOf ---
                // Without this, a single failure fails the whole allOf and
                // we lose every good card. Classic trap of 08-07.
                .exceptionally(e -> {
                    releasePermit();        // in case it failed before releasing it
                    failed.increment();
                    // The cause arrives WRAPPED in CompletionException.
                    Throwable cause = e.getCause() != null ? e.getCause() : e;
                    LOG.warning("Failure enriching " + isbn + ": "
                            + cause.getMessage());
                    return new EnrichedCard(isbn, null, null, 0, null,
                            "FAILED: " + cause.getClass().getSimpleName());
                });
    }

    /** Downloads the cover to disk. Returns null if it could not be done. */
    private CompletableFuture<Path> downloadCover(String url, String isbn) {
        if (!url.startsWith("http://") && !url.startsWith("https://")) {
            // Without this check, a "file:///etc/passwd" URL received
            // from the service would make us read local files.
            LOG.warning("Scheme not allowed in the cover of " + isbn);
            return CompletableFuture.completedFuture(null);
        }

        Path target = coverDirectory.resolve(isbn + ".jpg");

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Accept", "image/jpeg, image/png, image/*")
                .header("User-Agent", USER_AGENT)
                .timeout(Duration.ofSeconds(30))
                .GET()
                .build();

        acquirePermit();

        // BodyHandlers.ofFile writes straight to disk, without loading
        // the image into memory and without the copy loop of 09-05.
        return client.sendAsync(request, BodyHandlers.ofFile(target))
                .thenApply(response -> {
                    releasePermit();
                    if (response.statusCode() != 200) {
                        LOG.fine("Cover of " + isbn + ": HTTP "
                                + response.statusCode());
                        return null;
                    }
                    Path path = response.body();
                    try {
                        long size = Files.size(path);
                        if (size > MAX_COVER) {
                            Files.deleteIfExists(path);
                            LOG.warning("Cover of " + isbn + " too large");
                            return null;
                        }
                        coverBytes.add(size);
                    } catch (Exception e) {
                        LOG.fine("Could not check the size: " + e.getMessage());
                    }
                    coversDownloaded.increment();
                    return path;
                })
                .exceptionally(e -> {
                    releasePermit();
                    LOG.fine("Failure downloading the cover of " + isbn);
                    return null;    // no cover is not an enrichment failure
                });
    }

    // =================================================================
    // Semaphore
    // =================================================================

    private void acquirePermit() {
        try {
            permits.acquire();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();       // 08-02
            throw new CompletionException(e);
        }
    }

    private void releasePermit() {
        permits.release();
    }

    // =================================================================
    // JSON parsing: A TEACHING STOPGAP, see section 15
    // =================================================================

    /**
     * Extraction by substring search. It works with this service's flat
     * responses and breaks with nesting, arrays or repeated fields.
     * DOING IT PROPERLY IS JACKSON, AND THAT IS 11-07.
     */
    private String textField(String json, String field) {
        String mark = "\"" + field + "\"";
        int i = json.indexOf(mark);
        if (i < 0) {
            return null;
        }
        int colon = json.indexOf(':', i + mark.length());
        if (colon < 0) {
            return null;
        }
        int open = json.indexOf('"', colon);
        if (open < 0) {
            return null;
        }
        StringBuilder value = new StringBuilder();
        int j = open + 1;
        while (j < json.length()) {
            char c = json.charAt(j);
            if (c == '\\' && j + 1 < json.length()) {
                value.append(json.charAt(j + 1));
                j += 2;
                continue;
            }
            if (c == '"') {
                return value.toString();
            }
            value.append(c);
            j++;
        }
        return null;
    }

    private int intField(String json, String field) {
        String mark = "\"" + field + "\"";
        int i = json.indexOf(mark);
        if (i < 0) {
            return 0;
        }
        int j = json.indexOf(':', i + mark.length()) + 1;
        while (j < json.length() && !Character.isDigit(json.charAt(j))) {
            if (json.charAt(j) == ',' || json.charAt(j) == '}') {
                return 0;
            }
            j++;
        }
        int start = j;
        while (j < json.length() && Character.isDigit(json.charAt(j))) {
            j++;
        }
        return start == j ? 0 : Integer.parseInt(json.substring(start, j));
    }

    private boolean validIsbn(String isbn) {
        if (isbn == null || isbn.isBlank() || isbn.length() > 20) {
            return false;
        }
        // Allow list: it prevents injecting paths ("../admin") into the URL.
        for (int i = 0; i < isbn.length(); i++) {
            char c = isbn.charAt(i);
            if (!Character.isDigit(c) && c != '-') {
                return false;
            }
        }
        return true;
    }

    // =================================================================
    // Report and shutdown
    // =================================================================

    private void report(long ms, int total) {
        System.out.println();
        System.out.println("=== CATALOGUE ENRICHMENT ===");
        System.out.printf("%-26s %d%n", "Materials", total);
        System.out.printf("%-26s %d%n", "Enriched", enriched.sum());
        System.out.printf("%-26s %d%n", "Not found", notFound.sum());
        System.out.printf("%-26s %d%n", "Failed", failed.sum());
        System.out.printf("%-26s %d%n", "Covers downloaded",
                coversDownloaded.sum());
        System.out.printf("%-26s %.1f KB%n", "Cover bytes",
                coverBytes.sum() / 1024.0);
        System.out.printf("%-26s %.2f s%n", "Total time", ms / 1000.0);
        if (total > 0) {
            System.out.printf("%-26s %.1f ms%n", "Mean per material",
                    (double) ms / total);
        }
    }

    @Override
    public void close() {
        // Two-phase shutdown (08-05).
        executor.shutdown();
        try {
            if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
                LOG.warning("Requests still active; forcing the shutdown");
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();       // 08-02
        }
        LOG.info("CatalogEnricher closed");
    }
}

Running it

package com.nexussoftware.bibliotech.presentation;

import com.nexussoftware.bibliotech.network.CatalogEnricher;
import com.nexussoftware.bibliotech.network.CatalogEnricher.EnrichedCard;

import java.nio.file.Path;
import java.util.List;

public class EnricherTest {

    public static void main(String[] args) {
        List<String> isbns = List.of(
                "978-0000000001", "978-0000000002", "978-0000000003",
                "978-0000000004", "978-0000000005", "978-0000000006",
                "978-0000000007", "978-0000000008", "978-0000000009",
                "978-0000000010", "978-0000000011", "978-0000000012",
                "978-0000000013", "978-0000000014", "978-0000000015",
                "978-0000000016", "978-0000000017", "978-0000000018",
                "978-0000000019", "978-0000000020");

        // AutoCloseable: orderly shutdown of the executor.
        try (CatalogEnricher enricher = new CatalogEnricher(
                "http://localhost:8080", Path.of("covers"))) {

            List<EnrichedCard> cards = enricher.enrichIsbns(isbns);

            System.out.println("\n--- RESULT ---");
            for (EnrichedCard c : cards) {
                System.out.printf("  %-18s %-30s %s%n",
                        c.isbn(),
                        c.title() == null ? "-" : c.title(),
                        c.status());
            }
        }
    }
}

Output against a service taking 200 ms per query:

Enriching 20 materials (up to 8 at a time)...

=== CATALOGUE ENRICHMENT ===
Materials                  20
Enriched                   17
Not found                  2
Failed                     1
Covers downloaded          15
Cover bytes                682.4 KB
Total time                 1.24 s
Mean per material          62.0 ms

--- RESULT ---
  978-0000000001     Effective Java                 OK
  978-0000000002     Design Patterns                OK
  978-0000000003     Refactoring                    OK
  978-0000000004     -                              NOT FOUND
  ...
  978-0000000019     -                              FAILED: HttpTimeoutException

Compare it with the sequential synchroniser of 09-05. That one took 5.4 seconds for five materials, that is, more than a second per material. This one takes 1.24 seconds for twenty, with two requests each (metadata and cover): 62 ms per material. An improvement of more than seventeen times, with the same network traffic and without a single thread blocked waiting.

Notice the last line too: one request ran out of time and the remaining nineteen completed normally. That is the individual exceptionally doing its job. Without it, that single failure would have failed the allOf and the result would have been zero cards.

Common Mistakes and Tips

Creating an HttpClient per request. You lose the connection pool, the TLS sessions and HTTP/2 multiplexing, and you create a thread pool every time. It can multiply the latency by four and end up leaking threads. One per application, static final.

Assuming that a 4xx or 5xx throws an exception. It does not. body() will hold the server's error page and your parser will receive rubbish. Check statusCode() always.

Forgetting that followRedirects is NEVER by default. Unlike HttpURLConnection. If your migrated code stalls on a 301, this is why. Use NORMAL.

Not shielding every future with exceptionally before the allOf. This API's most expensive mistake: a single failure fails the whole allOf and you lose every good response.

Confusing thenApply with thenCompose. If the function returns another CompletableFuture, it is thenCompose. With thenApply you end up with a CompletableFuture<CompletableFuture<T>> and the compiler will tell you so in a not especially clear way.

Doing heavy work in a suffix-free thenApply. It runs on an internal HttpClient thread and throttles its ability to serve other responses. Expensive work, ...Async with your own executor.

Firing thousands of sendAsync calls with no bound. You earn a 429 or an IP block, and you swamp the service. Use a Semaphore or process in batches.

Not setting .timeout() on the request. The client's connectTimeout only covers the setup. Without the total limit, a slow server can hold you indefinitely.

Forgetting Thread.currentThread().interrupt() when catching InterruptedException. A rule from 08-02, and send is interruptible, so here it really applies.

Modifying an HttpRequest.Builder template without copy(). The next request inherits the previous state. Use .copy().

Logging headers or complete URIs. The Authorization ends up in the log, and so does a URL with a token in the query. Log the method, the path and the code.

Disabling TLS certificate validation. It removes all of HTTPS's security. If the certificate is internal, configure an SSLContext with your store (12-07).

Retrying a non-idempotent POST. You may duplicate the operation. Either you do not retry, or you use an idempotency key.

Parsing JSON with indexOf in production. It works until a value carries escaped quotes or the service nests an object. Jackson, in 11-07.

A migration tip. If you are moving code from HttpURLConnection to HttpClient, check three things in this order: followRedirects (the default changes), the status code check (there is no more getErrorStream, but you still have to look at statusCode()), and the request's .timeout() (new, and it is what really protects you). That resolves most of the surprises.

Exercises

Exercise 1: Resilient asynchronous HTTP client

Rewrite the ResilientHttpClient of 09-05 using HttpClient, but asynchronous: CompletableFuture<Response> get(String url).

Requirements:

  • A reused HttpClient, with its own executor of named threads.
  • Retries with increasing, randomised backoff, implemented inside the asynchronous chain with a recursive thenCompose. No Thread.sleep: use CompletableFuture.delayedExecutor(...) so as not to block a thread while you wait.
  • Retry only what is transient: HttpTimeoutException, ConnectException, 429, 502, 503, 504.
  • Respect Retry-After if it comes, with a cap of 30 s.
  • At most 4 attempts, with the number of attempts in the result.
  • A post method that does not retry by default and a postIdempotent that does, with an Idempotency-Key header the same on every retry.
  • Write a main that fires 10 requests at once against a service that fails randomly and shows the breakdown.

Exercise 2: Strategy comparator

Write StrategyComparator, which measures the four ways of making N requests and proves with numbers why the asynchronous one wins.

Requirements:

  • Strategy A: sequential with send in a loop.
  • Strategy B: parallel with an ExecutorService of 8 threads and blocking send (module 8's approach without CompletableFuture).
  • Strategy C: asynchronous with sendAsync + allOf, unbounded.
  • Strategy D: asynchronous with sendAsync + allOf bounded with a Semaphore to 8 simultaneous.
  • For each one: total time, requests per second, mean latency, and the maximum number of live threads during the run (with Thread.activeCount() sampled from a separate thread).
  • Discard a warm-up round before measuring each strategy.
  • A final comparison table with the improvement factor relative to A.
  • Comment on why B and D give similar times but consume very different resources.

Run it with N = 50 against a service taking 200 ms.

Exercise 3: Nexus Software service status dashboard

Write ServiceStatusDashboard, which periodically checks the health of every service BiblioTech depends on and shows a dashboard on the console.

Requirements:

  • A configurable list of services: name, health URL, and expected code.
  • Checking them all in parallel with sendAsync + allOf, with BodyHandlers.discarding() (the body is of no interest) and a short .timeout() of 3 s.
  • For each service: status (UP, DEGRADED if it replies but with an unexpected code, DOWN if there is an exception), latency in ms, HTTP code and negotiated version (HTTP/1.1 or HTTP/2).
  • A history of the last 20 checks per service, with an availability percentage and a status bar of the form ####-###-## (one per check).
  • Repetition every 10 s with a ScheduledExecutorService (08-05), with the task body wrapped in try/catch so that an exception does not cancel the scheduled task silently.
  • A two-phase graceful shutdown with a shutdown hook.
  • An alert in the log when a service goes from UP to DOWN or the other way round, without repeating it on every cycle.

Solutions

Solution 1

package com.nexussoftware.bibliotech.network;

import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;

/**
 * Asynchronous HTTP client with retries, over java.net.http.
 *
 * The key point of the exercise: the retries happen INSIDE the asynchronous
 * chain with a recursive thenCompose and delayedExecutor. No thread
 * blocks while waiting between attempts.
 */
public class ResilientAsyncClient implements AutoCloseable {

    private static final Logger LOG =
            Logger.getLogger(ResilientAsyncClient.class.getName());

    private static final int MAX_ATTEMPTS = 4;
    private static final long INITIAL_WAIT_MS = 200;
    private static final long MAX_RETRY_AFTER_MS = 30_000;

    private final HttpClient client;
    private final ExecutorService executor;

    public ResilientAsyncClient() {
        AtomicInteger n = new AtomicInteger(1);
        this.executor = Executors.newFixedThreadPool(8, r -> {
            Thread t = new Thread(r, "http-resilient-" + n.getAndIncrement());
            t.setDaemon(true);
            return t;
        });
        // ONE client, reused: connection pool and TLS sessions.
        this.client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .followRedirects(HttpClient.Redirect.NORMAL)
                .executor(executor)
                .build();
    }

    public record Response(int code, String body,
                           Map<String, List<String>> headers, int attempts) {

        public boolean success() {
            return code >= 200 && code < 300;
        }
    }

    // =================================================================
    // Public API
    // =================================================================

    public CompletableFuture<Response> get(String url) {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Accept", "application/json, */*")
                .header("User-Agent", "BiblioTech/1.0")
                .timeout(Duration.ofSeconds(10))
                .GET()
                .build();
        return withRetries(request, 1, INITIAL_WAIT_MS);
    }

    /**
     * POST WITHOUT retries. If it times out, we do not know whether the server
     * processed it; retrying could duplicate the operation.
     */
    public CompletableFuture<Response> post(String url, String body, String type) {
        return withRetries(buildPost(url, body, type, null),
                MAX_ATTEMPTS, 0);   // starting at the last attempt = no retries
    }

    /**
     * POST WITH retries, using an idempotency key.
     *
     * The key is generated ONCE, outside the chain, and travels the same on
     * every retry. That is what lets the server recognise that it is the
     * same logical operation and not repeat it.
     */
    public CompletableFuture<Response> postIdempotent(String url, String body,
                                                      String type) {
        String key = UUID.randomUUID().toString();
        return withRetries(buildPost(url, body, type, key),
                1, INITIAL_WAIT_MS);
    }

    private HttpRequest buildPost(String url, String body, String type,
                                  String idempotencyKey) {
        HttpRequest.Builder b = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Content-Type", type == null
                        ? "application/json; charset=utf-8" : type)
                .header("Accept", "application/json")
                .header("User-Agent", "BiblioTech/1.0")
                .timeout(Duration.ofSeconds(15))
                .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));

        if (idempotencyKey != null) {
            b = b.header("Idempotency-Key", idempotencyKey);
        }
        return b.build();
    }

    // =================================================================
    // Core: retries INSIDE the asynchronous chain
    // =================================================================

    private CompletableFuture<Response> withRetries(HttpRequest request,
                                                    int attempt, long wait) {

        return client.sendAsync(request, BodyHandlers.ofString(StandardCharsets.UTF_8))

                // --- Case 1: there was a response. It may be a transient code. ---
                .thenCompose(response -> {
                    int code = response.statusCode();

                    if (isTransient(code) && attempt < MAX_ATTEMPTS) {
                        long realWait = waitAfter(response, wait);
                        LOG.warning(request.method() + " " + request.uri().getPath()
                                + " -> " + code + "; retry " + (attempt + 1)
                                + " in " + realWait + " ms");

                        // HERE IS THE KEY POINT OF THE EXERCISE.
                        // delayedExecutor returns an Executor that runs
                        // the task AFTER the delay, blocking no thread.
                        // A Thread.sleep here would leave a pool thread parked
                        // for the whole wait, which is exactly what we want to avoid.
                        Executor delayed = CompletableFuture.delayedExecutor(
                                realWait, TimeUnit.MILLISECONDS, executor);

                        // supplyAsync on the delayed executor + thenCompose:
                        // the recursion becomes another stage of the chain.
                        return CompletableFuture
                                .supplyAsync(() -> null, delayed)
                                .thenCompose(v -> withRetries(request,
                                        attempt + 1, wait * 2));
                    }

                    return CompletableFuture.completedFuture(new Response(
                            code, response.body(), response.headers().map(), attempt));
                })

                // --- Case 2: there was an exception. It may be transient. ---
                .handle((result, error) -> {
                    if (error == null) {
                        return CompletableFuture.completedFuture(result);
                    }
                    // The cause arrives WRAPPED in CompletionException (08-07).
                    Throwable cause = error.getCause() != null ? error.getCause() : error;

                    if (isTransient(cause) && attempt < MAX_ATTEMPTS) {
                        long realWait = withJitter(wait);
                        LOG.warning(cause.getClass().getSimpleName() + " on "
                                + request.uri().getPath() + "; retry "
                                + (attempt + 1) + " in " + realWait + " ms");

                        Executor delayed = CompletableFuture.delayedExecutor(
                                realWait, TimeUnit.MILLISECONDS, executor);
                        return CompletableFuture
                                .supplyAsync(() -> null, delayed)
                                .thenCompose(v -> withRetries(request,
                                        attempt + 1, wait * 2));
                    }

                    // Permanent or out of attempts: the failure is propagated.
                    return CompletableFuture.<Response>failedFuture(cause);
                })
                // handle returns CompletableFuture<CompletableFuture<Response>>:
                // thenCompose flattens it. It is the map/flatMap of 08-07.
                .thenCompose(f -> f);
    }

    // =================================================================
    // Policy
    // =================================================================

    private boolean isTransient(int code) {
        // The 500 is excluded on purpose: it is usually a deterministic
        // server failure that will repeat identically.
        return code == 429 || code == 502 || code == 503 || code == 504;
    }

    private boolean isTransient(Throwable t) {
        // HttpTimeoutException is a subclass of IOException, and so is
        // ConnectException: the concrete ones must be checked BEFORE IOException.
        return t instanceof HttpTimeoutException
                || t instanceof ConnectException
                || (t instanceof IOException && !(t.getMessage() != null
                        && t.getMessage().contains("UnresolvedAddress")));
    }

    private long waitAfter(HttpResponse<?> response, long computed) {
        // firstValue returns Optional because the header may be absent.
        // Here we use it with isPresent()/get(); Optional's fluent style
        // (map, orElseGet, ifPresent) is covered in 10-04.
        java.util.Optional<String> retryAfter =
                response.headers().firstValue("Retry-After");

        if (retryAfter.isPresent()) {
            try {
                // The seconds format only; the date one requires parsing
                // HTTP dates, and that is done properly in 10-05.
                long ms = Long.parseLong(retryAfter.get().strip()) * 1000;
                return Math.min(ms, MAX_RETRY_AFTER_MS);
            } catch (NumberFormatException e) {
                LOG.fine("Retry-After in date format; ignored");
            }
        }
        return withJitter(computed);
    }

    /**
     * Randomises the wait by 20 %.
     * It avoids the "thundering herd": if a hundred clients fail at once and all
     * retry exactly at 200 ms, the synchronised burst takes down again
     * the service that was recovering, in an endless cycle.
     */
    private long withJitter(long base) {
        if (base <= 0) {
            return 0;
        }
        long variation = Math.max(1, base / 5);
        return base + ThreadLocalRandom.current().nextLong(-variation, variation + 1);
    }

    @Override
    public void close() {
        executor.shutdown();        // two-phase shutdown (08-05)
        try {
            if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }

    // =================================================================
    // Test
    // =================================================================

    public static void main(String[] args) {
        String base = args.length > 0 ? args[0] : "http://localhost:8080";

        try (ResilientAsyncClient c = new ResilientAsyncClient()) {

            List<CompletableFuture<Response>> futures = new java.util.ArrayList<>();
            long t0 = System.currentTimeMillis();

            for (int i = 1; i <= 10; i++) {
                futures.add(c.get(base + "/v1/books/978-000000000" + (i % 10))
                        // Individual shielding BEFORE the allOf: without it, one
                        // failure would take down all ten.
                        .exceptionally(e -> new Response(-1,
                                "FAILURE: " + e.getCause().getMessage(),
                                Map.of(), MAX_ATTEMPTS)));
            }

            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
            long ms = System.currentTimeMillis() - t0;

            int ok = 0, failed = 0, retried = 0;
            for (CompletableFuture<Response> f : futures) {
                Response r = f.join();      // safe: allOf has already finished
                if (r.success()) {
                    ok++;
                } else {
                    failed++;
                }
                if (r.attempts() > 1) {
                    retried++;
                }
                System.out.printf("  code=%-5d attempts=%d%n", r.code(), r.attempts());
            }

            System.out.println();
            System.out.printf("Successful: %d   Failed: %d   With retry: %d%n",
                    ok, failed, retried);
            System.out.printf("Total time: %d ms%n", ms);
        }
    }
}

Comments. The heart of the exercise is doing the retries without blocking any thread, and that is where CompletableFuture.delayedExecutor is the key piece. The naive solution would be a Thread.sleep(wait) inside a stage, but that leaves a pool thread parked for the whole wait — and with an 800 ms wait and ten requests retrying at once, the eight-thread pool is left with nothing. delayedExecutor returns an Executor that schedules the task for later on an internal timer, holding no thread in the meantime.

The recursion through thenCompose turns the retry into another stage of the same chain, instead of a loop. The future withRetries returns does not complete until the whole chain —with all its retries— finishes, and the caller never learns how many rounds there were except through the attempts field.

The handle followed by thenCompose(f -> f) deserves attention. handle is the only stage that sees both the result and the error, which is what we need in order to decide whether to retry; but since its function returns a CompletableFuture, the result is a future of a future, and it has to be flattened. It is exactly the map/flatMap distinction of 08-07, applied in a real situation.

And the idempotency key generated outside the chain is what makes postIdempotent correct: if it were generated inside, each retry would carry a different key and the server would treat them as different operations, which is exactly what we wanted to avoid.

Solution 2

package com.nexussoftware.bibliotech.network;

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.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;

/**
 * Compares four strategies for making N HTTP requests.
 * It proves with numbers why the bounded asynchronous one is the answer.
 */
public class StrategyComparator {

    private final String base;
    private final int requests;
    private final HttpClient client;

    public StrategyComparator(String base, int requests) {
        this.base = base;
        this.requests = requests;
        // ONE client for every strategy: that way the comparison is
        // fair and we do not measure the cost of creating clients.
        this.client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .build();
    }

    public record Result(String strategy, long ms, int successful, int failed,
                         double meanLatencyMs, int maxThreads) {
    }

    private HttpRequest requestFor(int i) {
        return HttpRequest.newBuilder()
                .uri(URI.create(base + "/v1/books/978-" + String.format("%010d", i)))
                .header("Accept", "application/json")
                .timeout(Duration.ofSeconds(15))
                .GET()
                .build();
    }

    // =================================================================
    // Thread watcher
    // =================================================================

    /** Samples Thread.activeCount() on a separate thread during the measurement. */
    private static class ThreadWatcher {
        private final AtomicInteger maximum = new AtomicInteger();
        private volatile boolean active = true;
        private Thread thread;

        void start() {
            thread = new Thread(() -> {
                while (active) {
                    maximum.updateAndGet(m -> Math.max(m, Thread.activeCount()));
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        return;
                    }
                }
            }, "thread-watcher");
            thread.setDaemon(true);
            thread.start();
        }

        int stop() {
            active = false;
            try {
                thread.join(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return maximum.get();
        }
    }

    // =================================================================
    // A: sequential
    // =================================================================

    public Result sequential() {
        ThreadWatcher watcher = new ThreadWatcher();
        watcher.start();

        int successful = 0, failed = 0;
        long latencySum = 0;
        long t0 = System.currentTimeMillis();

        for (int i = 0; i < requests; i++) {
            long p0 = System.nanoTime();
            try {
                HttpResponse<Void> r = client.send(requestFor(i),
                        BodyHandlers.discarding());
                if (r.statusCode() == 200) {
                    successful++;
                } else {
                    failed++;
                }
            } catch (IOException e) {
                failed++;
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();      // 08-02
                break;
            }
            latencySum += (System.nanoTime() - p0) / 1_000_000;
        }

        long ms = System.currentTimeMillis() - t0;
        return new Result("A. Sequential (send in a loop)", ms, successful, failed,
                requests == 0 ? 0 : (double) latencySum / requests,
                watcher.stop());
    }

    // =================================================================
    // B: thread pool with blocking send
    // =================================================================

    public Result blockingPool() throws InterruptedException {
        ThreadWatcher watcher = new ThreadWatcher();
        watcher.start();

        AtomicInteger successful = new AtomicInteger();
        AtomicInteger failed = new AtomicInteger();
        LongAdder latencySum = new LongAdder();

        ExecutorService pool = Executors.newFixedThreadPool(8);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch finish = new CountDownLatch(requests);

        for (int i = 0; i < requests; i++) {
            final int n = i;
            pool.execute(() -> {
                try {
                    start.await();      // they all start at once
                    long p0 = System.nanoTime();
                    // send BLOCKS the pool thread for the whole network
                    // wait. Eight threads = eight simultaneous requests,
                    // and seven of every eight threads sit doing nothing.
                    HttpResponse<Void> r = client.send(requestFor(n),
                            BodyHandlers.discarding());
                    latencySum.add((System.nanoTime() - p0) / 1_000_000);
                    if (r.statusCode() == 200) {
                        successful.incrementAndGet();
                    } else {
                        failed.incrementAndGet();
                    }
                } catch (IOException e) {
                    failed.incrementAndGet();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    finish.countDown();
                }
            });
        }

        long t0 = System.currentTimeMillis();
        start.countDown();
        finish.await();
        long ms = System.currentTimeMillis() - t0;

        pool.shutdown();
        if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
            pool.shutdownNow();
        }

        return new Result("B. Pool of 8 threads (blocking send)", ms,
                successful.get(), failed.get(),
                (double) latencySum.sum() / requests, watcher.stop());
    }

    // =================================================================
    // C: asynchronous, unbounded
    // =================================================================

    public Result asyncUnbounded() {
        ThreadWatcher watcher = new ThreadWatcher();
        watcher.start();

        AtomicInteger successful = new AtomicInteger();
        AtomicInteger failed = new AtomicInteger();
        LongAdder latencySum = new LongAdder();

        long t0 = System.currentTimeMillis();
        List<CompletableFuture<Void>> futures = new ArrayList<>(requests);

        for (int i = 0; i < requests; i++) {
            long p0 = System.nanoTime();
            futures.add(client.sendAsync(requestFor(i), BodyHandlers.discarding())
                    .thenAccept(r -> {
                        latencySum.add((System.nanoTime() - p0) / 1_000_000);
                        if (r.statusCode() == 200) {
                            successful.incrementAndGet();
                        } else {
                            failed.incrementAndGet();
                        }
                    })
                    // Individual shielding: without it, one failure fails the allOf.
                    .exceptionally(e -> {
                        failed.incrementAndGet();
                        return null;
                    }));
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        long ms = System.currentTimeMillis() - t0;

        return new Result("C. Async unbounded (sendAsync + allOf)", ms,
                successful.get(), failed.get(),
                (double) latencySum.sum() / requests, watcher.stop());
    }

    // =================================================================
    // D: asynchronous bounded with a Semaphore
    // =================================================================

    public Result asyncBounded(int simultaneous) {
        ThreadWatcher watcher = new ThreadWatcher();
        watcher.start();

        AtomicInteger successful = new AtomicInteger();
        AtomicInteger failed = new AtomicInteger();
        LongAdder latencySum = new LongAdder();
        Semaphore permits = new Semaphore(simultaneous);

        long t0 = System.currentTimeMillis();
        List<CompletableFuture<Void>> futures = new ArrayList<>(requests);

        for (int i = 0; i < requests; i++) {
            try {
                // The semaphore limits how many requests are IN FLIGHT,
                // not how many threads there are. That is the difference from strategy B.
                permits.acquire();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
            long p0 = System.nanoTime();
            futures.add(client.sendAsync(requestFor(i), BodyHandlers.discarding())
                    .thenAccept(r -> {
                        permits.release();
                        latencySum.add((System.nanoTime() - p0) / 1_000_000);
                        if (r.statusCode() == 200) {
                            successful.incrementAndGet();
                        } else {
                            failed.incrementAndGet();
                        }
                    })
                    .exceptionally(e -> {
                        permits.release();      // ALSO on failure, or it leaks
                        failed.incrementAndGet();
                        return null;
                    }));
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        long ms = System.currentTimeMillis() - t0;

        return new Result("D. Async bounded to " + simultaneous, ms,
                successful.get(), failed.get(),
                (double) latencySum.sum() / requests, watcher.stop());
    }

    // =================================================================
    // Running it
    // =================================================================

    public void compare() throws InterruptedException {
        // WARM-UP: the first round loads classes, compiles with the JIT
        // and establishes the first connections. Without discarding it, the first
        // strategy measured would be unfairly penalised.
        System.out.println("Warming up...");
        for (int i = 0; i < 5; i++) {
            try {
                client.send(requestFor(i), BodyHandlers.discarding());
            } catch (Exception ignored) {
                // The warm-up does not have to succeed.
            }
        }

        System.out.println("Measuring " + requests + " requests per strategy...\n");

        List<Result> results = new ArrayList<>();
        results.add(sequential());
        results.add(blockingPool());
        results.add(asyncUnbounded());
        results.add(asyncBounded(8));

        long reference = results.get(0).ms();

        System.out.printf("%-42s %9s %8s %10s %9s %8s%n",
                "STRATEGY", "TIME", "REQ/S", "MEAN LAT.", "THREADS", "GAIN");
        System.out.println("-".repeat(95));
        for (Result r : results) {
            System.out.printf("%-42s %8d ms %8.0f %8.1f ms %9d %7.1fx%n",
                    r.strategy(), r.ms(),
                    r.ms() == 0 ? 0 : requests * 1000.0 / r.ms(),
                    r.meanLatencyMs(), r.maxThreads(),
                    r.ms() == 0 ? 0 : (double) reference / r.ms());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        String base = args.length > 0 ? args[0] : "http://localhost:8080";
        new StrategyComparator(base, 50).compare();
    }
}

Output against a service taking 200 ms:

Warming up...
Measuring 50 requests per strategy...

STRATEGY                                        TIME    REQ/S  MEAN LAT.   THREADS     GAIN
-----------------------------------------------------------------------------------------------
A. Sequential (send in a loop)               10214 ms        5    204.1 ms         9     1.0x
B. Pool of 8 threads (blocking send)          1428 ms       35    221.6 ms        18     7.2x
C. Async unbounded (sendAsync + allOf)         287 ms      174    263.4 ms        14    35.6x
D. Async bounded to 8                         1391 ms       36    215.2 ms        12     7.3x

Comments. The numbers tell four stories.

A is the floor. 50 requests × 200 ms = exactly 10 seconds. Five requests per second, with the CPU idle 99.9 % of the time. It is the synchroniser of 09-05.

B and D give almost identical times (1428 against 1391 ms) because both limit to 8 simultaneous requests: 50/8 = 7 batches × 200 ms ≈ 1.4 s. But they consume very different resources, and that is the answer to what the exercise asked. In B, eight platform threads are blocked waiting for the network, each with its stack of up to a megabyte, doing absolutely nothing. In D, the semaphore limits the requests in flight, not the threads: the HTTP client's threads are free to process the responses of other requests. With eight requests the saving is anecdotal; with five hundred, B would need five hundred threads —half a gigabyte of stacks— and D would still use a handful.

C is the fastest (287 ms, 35 times better than A) because it launches all fifty at once. And for that very reason it is the most dangerous: fifty simultaneous requests against a real service earn you a 429 or an IP block, and if the service is internal, you can take it down yourself. Notice too that its mean latency is the highest (263 ms against A's 204): the requests get in each other's way because the server has to serve fifty at once. It goes faster overall but every individual request goes worse.

The conclusion is D, even though it is not the fastest on paper. It is fast, bounded, respectful of the service and sustainable with thousands of requests without leaking threads. In real systems, the correct strategy is almost never the fastest in a microbenchmark: it is the one that keeps working when the load multiplies by ten.

Solution 3

package com.nexussoftware.bibliotech.network;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Status dashboard of the services BiblioTech depends on.
 * It checks them all in parallel every N seconds and shows a console dashboard.
 */
public class ServiceStatusDashboard implements AutoCloseable {

    private static final Logger LOG =
            Logger.getLogger(ServiceStatusDashboard.class.getName());

    private static final int HISTORY_SIZE = 20;
    private static final int LIMIT_MS = 3_000;

    /** A service to watch. */
    public record Service(String name, String url, int expectedCode) {
    }

    public enum Status {
        UP('#'), DEGRADED('-'), DOWN('.');

        final char symbol;

        Status(char symbol) {
            this.symbol = symbol;
        }
    }

    /** The result of one check. */
    public record Check(Status status, int code, long latencyMs,
                        String version, String detail) {
    }

    private final List<Service> services;
    private final HttpClient client;
    private final ScheduledExecutorService scheduler;

    /**
     * History per service. ConcurrentHashMap because the scheduler thread
     * writes it and the asynchronous stages read it (08-06).
     */
    private final Map<String, Deque<Check>> history = new ConcurrentHashMap<>();
    /** Last known status, so as not to repeat the alert on every cycle. */
    private final Map<String, Status> lastStatus = new ConcurrentHashMap<>();

    private final AtomicInteger cycles = new AtomicInteger();

    public ServiceStatusDashboard(List<Service> services) {
        this.services = services;

        AtomicInteger n = new AtomicInteger(1);
        this.client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(2))
                .followRedirects(HttpClient.Redirect.NORMAL)
                .executor(Executors.newFixedThreadPool(services.size(), r -> {
                    Thread t = new Thread(r, "dashboard-http-" + n.getAndIncrement());
                    t.setDaemon(true);
                    return t;
                }))
                .build();

        this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread t = new Thread(r, "dashboard-scheduler");
            t.setDaemon(true);
            return t;
        });

        for (Service s : services) {
            history.put(s.name(), new ArrayDeque<>(HISTORY_SIZE));
            lastStatus.put(s.name(), Status.UP);
        }
    }

    // =================================================================
    // Startup
    // =================================================================

    public void start(int intervalSeconds) {
        // scheduleAtFixedRate from 08-05.
        scheduler.scheduleAtFixedRate(this::safeCycle,
                0, intervalSeconds, TimeUnit.SECONDS);
        LOG.info("Status dashboard running (every " + intervalSeconds + " s)");
    }

    /**
     * MANDATORY safety wrapper.
     *
     * If an exception escapes a scheduleAtFixedRate task, the task
     * IS CANCELLED SILENTLY and the dashboard stops updating without anybody
     * noticing: no exception, no log, nothing. It is the trap of 08-05.
     */
    private void safeCycle() {
        try {
            cycle();
        } catch (RuntimeException e) {
            LOG.log(Level.SEVERE, "Failure in the dashboard cycle", e);
        }
    }

    // =================================================================
    // One check cycle
    // =================================================================

    private void cycle() {
        long t0 = System.currentTimeMillis();

        // 1. Launch ALL the checks in parallel.
        List<CompletableFuture<Void>> futures = new ArrayList<>(services.size());
        for (Service s : services) {
            futures.add(check(s));
        }

        // 2. Wait for them all. join() here blocks the scheduler thread,
        //    which is correct: we do not want to paint a half-finished
        //    dashboard or overlap two cycles.
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

        cycles.incrementAndGet();
        render(System.currentTimeMillis() - t0);
    }

    private CompletableFuture<Void> check(Service service) {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(service.url()))
                .header("User-Agent", "BiblioTech-Dashboard/1.0")
                .header("Accept", "*/*")
                .timeout(Duration.ofMillis(LIMIT_MS))       // TOTAL limit
                .GET()
                .build();

        long start = System.nanoTime();

        // discarding(): only the code is of interest, not the body. But it
        // CONSUMES it, which is what allows the connection to be reused.
        return client.sendAsync(request, BodyHandlers.discarding())
                .thenAccept(response -> {
                    long ms = (System.nanoTime() - start) / 1_000_000;
                    int code = response.statusCode();

                    Status status = code == service.expectedCode()
                            ? Status.UP
                            : Status.DEGRADED;

                    store(service, new Check(status, code, ms,
                            response.version().toString(),
                            status == Status.DEGRADED
                                    ? "expected " + service.expectedCode() : ""));
                })
                // Individual shielding: a service that is down cannot stop
                // the others being checked or the dashboard being painted.
                .exceptionally(e -> {
                    long ms = (System.nanoTime() - start) / 1_000_000;
                    Throwable cause = e.getCause() != null ? e.getCause() : e;
                    store(service, new Check(Status.DOWN, -1, ms,
                            "-", cause.getClass().getSimpleName()));
                    return null;
                });
    }

    private void store(Service service, Check c) {
        Deque<Check> queue = history.get(service.name());
        synchronized (queue) {      // ArrayDeque is not thread-safe
            if (queue.size() >= HISTORY_SIZE) {
                queue.removeFirst();
            }
            queue.addLast(c);
        }

        // Alert ONLY on a status change, not on every cycle: a
        // service down for an hour would generate 360 identical alerts.
        Status previous = lastStatus.put(service.name(), c.status());
        if (previous != c.status()) {
            if (c.status() == Status.DOWN) {
                LOG.severe("ALERT: " + service.name() + " has GONE DOWN ("
                        + c.detail() + ")");
            } else if (previous == Status.DOWN) {
                LOG.info("RECOVERED: " + service.name() + " is replying again");
            } else {
                LOG.warning("CHANGE: " + service.name() + " " + previous
                        + " -> " + c.status());
            }
        }
    }

    // =================================================================
    // Painting
    // =================================================================

    private void render(long cycleMs) {
        StringBuilder sb = new StringBuilder();
        sb.append("\033[H\033[2J");     // clear the screen
        sb.append("=== STATUS DASHBOARD - BIBLIOTECH ===\n");
        sb.append(String.format("Cycle %d   check in %d ms%n%n",
                cycles.get(), cycleMs));
        sb.append(String.format("%-22s %-11s %7s %6s %-9s %-22s %7s%n",
                "SERVICE", "STATUS", "LATENCY", "CODE", "VERSION",
                "HISTORY", "AVAIL."));
        sb.append("-".repeat(96)).append('\n');

        for (Service s : services) {
            Deque<Check> queue = history.get(s.name());
            List<Check> copy;
            synchronized (queue) {
                copy = new ArrayList<>(queue);
            }
            if (copy.isEmpty()) {
                continue;
            }
            Check last = copy.get(copy.size() - 1);

            // History bar and availability calculation.
            StringBuilder bar = new StringBuilder();
            int up = 0;
            for (Check c : copy) {
                bar.append(c.status().symbol);
                if (c.status() == Status.UP) {
                    up++;
                }
            }
            double availability = 100.0 * up / copy.size();

            sb.append(String.format("%-22s %-11s %6d ms %6s %-9s %-22s %6.1f%%%n",
                    s.name(),
                    last.status(),
                    last.latencyMs(),
                    last.code() < 0 ? "-" : String.valueOf(last.code()),
                    last.version().replace("HTTP_", "HTTP/"),
                    bar,
                    availability));

            if (!last.detail().isEmpty()) {
                sb.append(String.format("  %-20s %s%n", "", last.detail()));
            }
        }

        sb.append("-".repeat(96)).append('\n');
        sb.append("Key: # up   - degraded   . down\n");
        System.out.print(sb);
    }

    @Override
    public void close() {
        // Two-phase shutdown (08-05).
        scheduler.shutdown();
        try {
            if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
                scheduler.shutdownNow();
            }
        } catch (InterruptedException e) {
            scheduler.shutdownNow();
            Thread.currentThread().interrupt();       // 08-02
        }
        LOG.info("Status dashboard stopped after " + cycles.get() + " cycles");
    }

    // =================================================================
    // Startup
    // =================================================================

    public static void main(String[] args) throws InterruptedException {
        List<Service> services = List.of(
                new Service("catalog-btcp", "http://localhost:9092/", 200),
                new Service("metadata", "http://localhost:8080/health", 200),
                new Service("covers", "http://localhost:8081/health", 200),
                new Service("notices", "http://localhost:8082/health", 200),
                new Service("missing", "http://localhost:9999/health", 200));

        ServiceStatusDashboard dashboard = new ServiceStatusDashboard(services);

        // Shutdown hook: Ctrl+C, SIGTERM from Docker or systemd (09-03).
        Runtime.getRuntime().addShutdownHook(
                new Thread(dashboard::close, "dashboard-shutdown"));

        dashboard.start(10);

        // The scheduler uses daemon threads: main has to be kept alive.
        Thread.currentThread().join();
    }
}

Output:

=== STATUS DASHBOARD - BIBLIOTECH ===
Cycle 14   check in 3012 ms

SERVICE                STATUS      LATENCY   CODE VERSION   HISTORY                 AVAIL.
------------------------------------------------------------------------------------------------
catalog-btcp           UP                4 ms    200 HTTP/1_1  ##############          100.0%
metadata               UP               38 ms    200 HTTP/2    #############-           92.9%
  expected 200
covers                 UP               21 ms    200 HTTP/2    ##############          100.0%
notices                DEGRADED        104 ms    503 HTTP/1_1  ###########---           78.6%
  expected 200
missing                DOWN           2001 ms      - -         ..............            0.0%
  ConnectException
------------------------------------------------------------------------------------------------
Key: # up   - degraded   . down

Comments. Four points this exercise makes clear.

The three states are not decoration. DEGRADED —it replies, but with an unexpected code— is different information from DOWN —it does not reply at all. The notices service returning 503 is alive, started, reachable and overloaded; the missing one does not even have anybody listening. Confusing them sends the systems team to investigate the wrong problem, and it is the same distinction between ConnectException and SocketTimeoutException that you learned in 09-02.

The safeCycle is mandatory, not defensive. If an exception escapes a scheduleAtFixedRate task, the task is cancelled silently: the dashboard stops updating, there is no exception, no log, and nobody notices until somebody asks why the data is three hours old. It is the trap of 08-05, and in a monitoring dashboard it would be especially ironic.

The alert only on a status change is what tells a useful tool from one that gets ignored. A service down for an hour, checked every ten seconds, would generate 360 identical SEVERE lines. With lastStatus one is logged when it goes down and another when it recovers. Alert fatigue is a real problem: when everything alerts, nothing alerts.

And notice the cycle duration: 3012 ms, exactly the 3-second .timeout(). Four services reply in tens of milliseconds and the fifth runs out of time; since they are checked in parallel, the cycle lasts as long as the slowest and not the sum. Sequentially it would also be 3.2 seconds, but with ten services down it would be thirty seconds instead of three. That is the allOf doing what it is asked.

Conclusion

You have closed module 9, and with it BiblioTech has stopped being alone.

You know the modern API and its design: three immutable piecesHttpClient (who), HttpRequest (what) and HttpResponse<T> (what came back)— built with fluent builders, thread-safe and with no configuration by side effect. Gone is the mutable object with states that changed the method when output was enabled.

You know how to create the client with what matters —connectTimeout, followRedirects (which defaults to NEVER, unlike the old API) and your own executor with named threads— and above all you know the rule that decides the most performance: create one and reuse it, because it holds the connection pool, the TLS sessions and the multiplexed HTTP/2 connections. Creating one per request multiplies the latency by four against a remote service and leaks threads until the application falls over.

You build requests with uri, header, the methods and —the improvement that did not exist— timeout(), the total limit of the request, which is the only thing that protects you from a server that sends one byte every nine seconds and keeps a per-operation-limited read alive indefinitely. You can handle the BodyPublishers for sending —with ofFile streaming without loading into memory— and the BodyHandlers for receiving, which determine the response's type T: ofString with a charset, ofFile downloading to disk in one call, ofInputStream, discarding. And you know how to read HttpResponse<T>: statusCode, body, headers with decent methods, and uri() with the final URI after the redirects.

And above all: sendAsync returns a CompletableFuture<HttpResponse<String>>, and with that all of 08-07 applies with no adaptation. thenApply to transform, thenCompose when the function returns another future —with the CompletableFuture<CompletableFuture<T>> that appears when you get it wrong—, thenCombine to join two independent ones in parallel, orTimeout for the whole chain, handle when you need to see result and error at once, and allOf for N simultaneous requests, with the three rules of the pattern: launch them all before waiting for any, shield every future with its own exceptionally before aggregating it —without that, a single failure takes down all N responses—, and join() after the allOf, where it is already safe.

You are completely clear about the distinction that causes the most bugs: there is an exception when no HTTP response could be obtained; if there is a response, the network succeeded even if the code is 500. A 404 or a 503 throws nothing and body() will hold the server's error page. Checking statusCode() is not optional.

You know what the modern API gains over the old one —verbosity, immutability, thread safety, a total limit, asynchrony, HTTP/2 with multiplexing that makes twenty requests to the same host use a single connection, WebSocket, and a single body stream instead of the normal-versus-error distinction—, and what good practices govern calls to services you do not control: time limits always, retrying only what is transient with increasing, randomised backoff, never retrying a non-idempotent POST except with an idempotency key, a circuit breaker when a service has failed twenty times in a row, and never logging tokens or credentials —not even a URI with the token in the query—, picking up 06-07. With TLS and certificate validation pointing to 12-07.

And you have seen, flagged without disguise, the JSON stopgap: extracting fields with indexOf works with flat responses and breaks with escapes, nesting or arrays. Doing it properly is Jackson, and that is 11-07, where one line replaces fifty and returns the record already built.

BiblioTech, at the close of module 9, has left its machine.

Its catalogue server speaks BTCP/1 on port 9090 and serves Marta, Diego and Nuria at the same time with a bounded pool of named threads, validating everything that arrives by allow list, reading bounded lines so nobody can exhaust its memory, evicting for inactivity the clients that go quiet, refusing courteously when it is overloaded and shutting down in two phases with a shutdown hook. Its client connects with a time limit, verifies the greeting and the version before talking, validates its own arguments against line-break injection and says goodbye politely. Its UDP discovery lets the workstations find the server by shouting to the local network, and the manual IP configuration on every desk has disappeared. Its telemetry sends metrics every few seconds without ever blocking and without caring whether the collector is switched off. And its asynchronous enricher queries twenty ISBNs and downloads their covers in 1.24 seconds —62 ms per material against more than a second in the sequential version— bounding the parallelism with a semaphore, without blocking a single thread, and with one request that ran out of time without taking the other nineteen with it.

Of the shortcoming you declared at the close of module 8, nothing remains: BiblioTech is no longer a program shut inside one computer. It is queried from any desk at Nexus Software and it talks to external services.

But the code is starting to repeat itself in a way you can no longer ignore. CatalogClient, MetadataClient and CatalogEnricher repeat the same structure of request, code check, parsing and error translation, changing only the type of the result — and you have no way of writing "this is a client of something that returns things of type T" without duplicating the whole class. Every time you need to know whether a class has a certain field, or to mark a method as "do not test in production", you end up writing a naming convention that nobody checks. Your collection traversals are still verbose loops with accumulators and flags, when what you want to say is "of these materials, the ones on loan, sorted by title" — and you have had to dodge stream() throughout the module. Your null values still mean "not found" and still produce NullPointerException when somebody forgets to check them. BiblioTech's dates are still int days, a stopgap you have dragged along since module 3 and that makes it impossible to answer "how many days late is this loan?" without manual, error-prone arithmetic. And all your code runs on a JVM whose behaviour —how it allocates memory, when it frees it, what the JIT compiler does with your loops, why the first request is always the slowest— is still a black box you have only glimpsed sideways in the measurements.

In module 10, Advanced Topics, you open that box. You will see generics for writing code that works with any type without giving up the compiler's checking —and you will finally understand exactly what the <T> of HttpResponse<T> and the <String> you have been using all module mean—; annotations for adding information to the code that other tools can read, and reflection for inspecting and manipulating classes at run time, which is the magic Spring, Hibernate and JUnit are built on. You will see Java 8: the Streams API, which turns your nested loops into a declarative description of what you want, and Optional, which makes it impossible to forget to check for absence. You will see java.time, and BiblioTech's dates will finally stop being integers. You will see Java 9 and beyond: the module system, sealed, the pattern matching that simplifies hierarchies, and virtual threads, which —as you already anticipated in 09-03— completely change the calculation of "one thread per connection". And you will finish with memory, garbage collection and performance, where the JVM will stop being a black box and you will understand why your code runs at the speed it runs.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved