I/O, Streams & HTTP · Lesson 11

A reusable HttpClient with an offline handler

Exercise actual HTTP message APIs without sending any public request.

Keep HTTP message behavior separate from transport

HttpClient is an HTTP message API with a configurable handler underneath it. The handler determines how a request is processed; a supplied HttpMessageHandler can return a real HttpResponseMessage without opening a socket. This separation lets the lesson exercise request construction, asynchronous completion, content reading and ownership inside SharpRune's browser runtime. The address under offline.invalid is descriptive data supplied to the handler. No DNS lookup, TLS exchange or remote service is part of this exercise. When moving the same calling code to a production application, transport configuration and server integration become separate responsibilities.

Trace two calls through the same handler

The sample constructs one OfflineHandler and one HttpClient before entering the loop. Each iteration creates a new GET request, awaits SendAsync and reads the returned content. The handler checks its cancellation token, increments Calls and creates a response containing reply:1 or reply:2. Consequently the first two output lines are reply:1 and reply:2, followed by calls:2. One counter belongs to one handler instance throughout this run. Creating another handler for each iteration would reset that counter and change the evidence; it would not demonstrate client reuse.

Give the client, handler and messages explicit owners

The request and response each have an iteration-scoped using declaration, so both are disposed when that iteration ends. Disposing a response also disposes its owned content. The client lives across both iterations, and disposeHandler:false leaves handler disposal to its separate using declaration. Reverse declaration order means the client is disposed before the externally owned handler. That choice is visible rather than accidental. In production, long-lived clients or factory-managed handlers address transport lifetime concerns; this two-request in-memory lab does not measure connection pooling, DNS freshness, socket exhaustion or the factory's behavior.

An injected handler does not prove network integration

A handler that always returns the same successful body can hide a mistake in the request. The baseline deliberately focuses on lifetime and call count, so it does not reject an incorrect path, verb or missing header. A green-looking transcript therefore cannot establish those contracts. Similarly, a callback counter is not a count of packets or server-side operations. HttpClient may have its own validation and cancellation behavior before invoking the handler. State explicitly which observations come from your handler and which come from the framework, especially when replacing a real service during a test.

Practice: route by the requested address

Change OfflineHandler to require GET and the absolute path /count. Return an explicit non-success response for a different path, then send one accepted request and one deliberately wrong request. Write the expected status and content for each before running. Keep request and response disposal inside the loop. The useful hint is to inspect request.Method and request.RequestUri rather than searching a stringified request for a substring. Check the final call count separately: accepting or rejecting a message is a policy outcome, while reaching the handler is another observation.

Practice: verify disposal independently

Add an observable disposal flag to a small handler subclass by overriding Dispose(bool), calling the base implementation, and recording only explicit disposal. Put the client in a nested scope with disposeHandler:false, inspect the flag after that scope and dispose the handler afterward. Predict false before handler disposal and true afterward. Repeat with disposeHandler:true using a fresh handler. This isolates ownership without relying on garbage collection or a finalizer. Do not make a still-in-use client share a handler that another owner disposes; the exercise examines a controlled lifetime boundary.

Review the boundary you actually exercised

The successful run establishes that the shipped runtime can compose real HTTP request, response and content objects through a deliberately offline handler. It also exposes a reusable client lifetime and independent request lifetimes. Those are valuable application-level facts, but authentication, redirects, cookies, proxies, TLS, service availability and remote side effects remain outside this model. Keep a small set of focused offline checks for message policy, then add explicit integration tests against a controlled service when developing the production adapter. Preserve the distinction in test names and failure messages.

  • Reuse a client within its intended owner lifetime; create a fresh request for each send.
  • An offline handler checks message behavior, not a network connection.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;

using var handler = new OfflineHandler(); using var client = new HttpClient(handler, disposeHandler: false);
for (int i = 0; i < 2; i++) {
    using var request = new HttpRequestMessage(HttpMethod.Get, "https://offline.invalid/count");
    using var response = await client.SendAsync(request, CancellationToken.None);
    Console.WriteLine(await response.Content.ReadAsStringAsync(CancellationToken.None));
}
Console.WriteLine("calls:" + handler.Calls);
sealed class OfflineHandler : HttpMessageHandler {
    public int Calls { get; private set; }
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) { token.ThrowIfCancellationRequested(); Calls++; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("reply:" + Calls, Encoding.UTF8, "text/plain") }); }
}

Expected output

reply:1
reply:2
calls:2
Try this
The handler is the offline transport; reuse the client and dispose each request/response at its own boundary.

Further reading