The API is up and tested. The Windows program does not use it yet: It still reads and writes through the DataSet. In this lesson that changes, function by function. You know the pattern from lesson M1.4: The whole program is not switched at once, but one seam after the other, and for every seam there is a way back. Here the way back is called a switch: For every function that has been moved, the configuration says whether it takes the old or the new path. If the API goes down or the new function causes trouble, IT flips the switch back, and the program works as before, without anyone installing anything new.
Two functions move. The order overview becomes a new window that only reads. Billing stays in the old window, but calls the API. Both show what a desktop program has to do differently as a client: The DataSet in memory never waited for anything, a call over the network always waits, can fail, and can take forever. The main part is about that.
The client
The client is a library of its own, because the Blazor pages will use it as well (lesson M4.3). It sits between the contract and the network:
dotnet new classlib -n Brandt.OrderBook.Client -f net10.0
Remove-Item Brandt.OrderBook.Client/Class1.cs
dotnet sln Brandt.OrderBook.sln add Brandt.OrderBook.Client/Brandt.OrderBook.Client.csproj
dotnet add Brandt.OrderBook.Client/Brandt.OrderBook.Client.csproj reference Brandt.OrderBook.Contracts/Brandt.OrderBook.Contracts.csproj
dotnet add Brandt.OrderBook.Client/Brandt.OrderBook.Client.csproj package Microsoft.Extensions.Http.Resilience --version "10.*"
dotnet add Brandt.OrderBook/Brandt.OrderBook.csproj reference Brandt.OrderBook.Client/Brandt.OrderBook.Client.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Brandt.OrderBook.Contracts\Brandt.OrderBook.Contracts.csproj" />
</ItemGroup>
</Project>
The first decision is in the return type. Every call returns an ApiResult<T>: either the value or an ApiError with a kind. The alternative would be one exception per error.
But expected errors are not exceptions, they are everyday life: The network is gone, someone else was faster, the order has been deleted. A form has to be able to show them
without a try at every place, and the compiler does not force you to check, but result.Value throws if you overlooked the error.
namespace Brandt.OrderBook.Client;
public enum ApiErrorKind
{
Unreachable,
TimedOut,
NotFound,
Conflict,
Invalid,
ServerError,
Unexpected,
}
public sealed record ApiError(ApiErrorKind Kind, string Message, IReadOnlyDictionary<string, string[]>? Fields = null);
// Expected errors are a result, not an exception: A form has to be able to show them without try/catch at every place.
public sealed class ApiResult<T>
{
private readonly T? _value;
private ApiResult(T? value, ApiError? error)
{
_value = value;
Error = error;
}
public ApiError? Error { get; }
public bool IsOk => Error is null;
public T Value => IsOk ? _value! : throw new InvalidOperationException(Error!.Message);
public static ApiResult<T> Success(T value) => new(value, null);
public static ApiResult<T> Failure(ApiError error) => new(default, error);
}
The client itself:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Brandt.OrderBook.Logic;
using Brandt.OrderBook.Contracts;
using Polly.Timeout;
namespace Brandt.OrderBook.Client;
public interface IOrderApi
{
Task<ApiResult<OrdersPage>> ReadAsync(OrderStatus? status, int page, int pageSize, CancellationToken ct = default);
Task<ApiResult<OrderDetailDto>> ReadOneAsync(int orderId, CancellationToken ct = default);
Task<ApiResult<OrderDetailDto>> ChangeAsync(int orderId, OrderChangeDto change, CancellationToken ct = default);
Task<ApiResult<BillingDto>> BillAsync(int orderId, CancellationToken ct = default);
}
public sealed class OrderApiClient(HttpClient http) : IOrderApi
{
private static readonly JsonSerializerOptions Json = ContractJson.New();
public Task<ApiResult<OrdersPage>> ReadAsync(OrderStatus? status, int page, int pageSize, CancellationToken ct = default)
{
string query = $"page={page}&pageSize={pageSize}" + (status is { } chosen ? $"&status={chosen}" : string.Empty);
return SendAsync<OrdersPage>(() => http.GetAsync($"api/v1/orders?{query}", ct), ct);
}
public Task<ApiResult<OrderDetailDto>> ReadOneAsync(int orderId, CancellationToken ct = default) =>
SendAsync<OrderDetailDto>(() => http.GetAsync($"api/v1/orders/{orderId}", ct), ct);
public Task<ApiResult<OrderDetailDto>> ChangeAsync(int orderId, OrderChangeDto change, CancellationToken ct = default) =>
SendAsync<OrderDetailDto>(() => http.PutAsJsonAsync($"api/v1/orders/{orderId}", change, Json, ct), ct);
public Task<ApiResult<BillingDto>> BillAsync(int orderId, CancellationToken ct = default) =>
SendAsync<BillingDto>(() => http.PostAsync($"api/v1/orders/{orderId}/billing", null, ct), ct);
private static async Task<ApiResult<T>> SendAsync<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
{
try
{
using HttpResponseMessage response = await send();
if (!response.IsSuccessStatusCode)
{
return ApiResult<T>.Failure(await ReadErrorAsync(response, ct));
}
T? value = await response.Content.ReadFromJsonAsync<T>(Json, ct);
return value is null
? ApiResult<T>.Failure(new ApiError(ApiErrorKind.Unexpected, "The response of the API was empty."))
: ApiResult<T>.Success(value);
}
catch (TimeoutRejectedException)
{
// The timeout of the resilience layer (see the registration).
return ApiResult<T>.Failure(new ApiError(ApiErrorKind.TimedOut, "The API did not respond in time."));
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
// HttpClient.Timeout: a TaskCanceledException that the caller did not trigger.
return ApiResult<T>.Failure(new ApiError(ApiErrorKind.TimedOut, "The API did not respond in time."));
}
catch (HttpRequestException ex)
{
return ApiResult<T>.Failure(new ApiError(ApiErrorKind.Unreachable, "The API is not reachable: " + ex.Message));
}
catch (JsonException)
{
return ApiResult<T>.Failure(new ApiError(ApiErrorKind.Unexpected, "The response of the API is not readable."));
}
}
private static async Task<ApiError> ReadErrorAsync(HttpResponseMessage response, CancellationToken ct)
{
ProblemResponse? problem = null;
try
{
problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(Json, ct);
}
catch (JsonException)
{
// Not problem JSON, for example the error page of a proxy: It stays with the status code.
}
string message = problem?.Title ?? $"The API answered with {(int)response.StatusCode}.";
ApiErrorKind kind = response.StatusCode switch
{
HttpStatusCode.NotFound => ApiErrorKind.NotFound,
HttpStatusCode.Conflict => ApiErrorKind.Conflict,
HttpStatusCode.BadRequest or HttpStatusCode.UnprocessableEntity => ApiErrorKind.Invalid,
>= HttpStatusCode.InternalServerError => ApiErrorKind.ServerError,
_ => ApiErrorKind.Unexpected,
};
return new ApiError(kind, message, problem?.Errors);
}
// The API's problem JSON (RFC 9457), as far as the client needs it.
private sealed record ProblemResponse(string? Title, string? Detail, int? Status, Dictionary<string, string[]>? Errors);
}
The catch clauses at the end of SendAsync are the most important part. They separate what Microsoft's documentation on timeouts describes as separate cases:
| Exception | When | Meaning for the form |
|---|---|---|
HttpRequestException |
connection refused, name not found | Unreachable: check the network or the service |
TaskCanceledException with a TimeoutException inside |
HttpClient.Timeout has expired (default 100 seconds) |
TimedOut |
TimeoutRejectedException |
the timeout of the standard resilience layer has expired | TimedOut |
TaskCanceledException that the caller triggered itself |
the user closes the window, a new call replaces the old one | no error: the cancellation is passed on |
There are two pitfalls in the table. TimeoutRejectedException is neither a TimeoutException nor an HttpRequestException; a
catch (HttpRequestException) does not catch it. And a TaskCanceledException does not automatically mean "time ran out": Whoever closes the window cancels on their own, and
then no error message should appear. That is why the catch has the condition when (!ct.IsCancellationRequested).