Die API steht und ist getestet. Das Windows-Programm benutzt sie noch nicht: Es liest und schreibt weiter über das DataSet. In dieser Lektion ändert sich das, Funktion für Funktion. Das Muster kennen Sie aus Lektion M1.4: Nicht das ganze Programm wird auf einmal umgestellt, sondern eine Naht nach der anderen, und für jede Naht gibt es einen Rückweg. Hier heißt der Rückweg Schalter: Für jede umgestellte Funktion steht in der Konfiguration, ob sie den alten oder den neuen Weg nimmt. Fällt die API aus oder macht die neue Funktion Ärger, stellt die IT den Schalter zurück, und das Programm arbeitet wieder wie vorher, ohne dass jemand etwas neu installiert.

Zwei Funktionen ziehen um. Die Auftragsübersicht wird ein neues Fenster, das nur liest. Das Abrechnen bleibt im alten Fenster, ruft aber die API. Beides zeigt, was ein Desktop-Programm als Client anders machen muss: Das DataSet im Speicher hat nie auf etwas gewartet, ein Aufruf über das Netz wartet immer, kann scheitern und kann ewig dauern. Davon handelt der Hauptteil.

Der Client

Der Client ist eine eigene Bibliothek, weil ihn auch die Blazor-Seiten benutzen werden (Lektion M4.3). Er steht zwischen dem Vertrag und dem Netz:

PowerShell
dotnet new classlib -n Brandt.Auftragsbuch.Client -f net10.0
Remove-Item Brandt.Auftragsbuch.Client/Class1.cs
dotnet sln Brandt.Auftragsbuch.sln add Brandt.Auftragsbuch.Client/Brandt.Auftragsbuch.Client.csproj
dotnet add Brandt.Auftragsbuch.Client/Brandt.Auftragsbuch.Client.csproj reference Brandt.Auftragsbuch.Vertraege/Brandt.Auftragsbuch.Vertraege.csproj
dotnet add Brandt.Auftragsbuch.Client/Brandt.Auftragsbuch.Client.csproj package Microsoft.Extensions.Http.Resilience --version "10.*"
dotnet add Brandt.Auftragsbuch/Brandt.Auftragsbuch.csproj reference Brandt.Auftragsbuch.Client/Brandt.Auftragsbuch.Client.csproj
XML
<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.Auftragsbuch.Vertraege\Brandt.Auftragsbuch.Vertraege.csproj" />
  </ItemGroup>

</Project>

Die erste Entscheidung steht im Rückgabetyp. Jeder Aufruf liefert ein ApiErgebnis<T>: entweder den Wert oder einen ApiFehler mit einer Art. Das Gegenstück wäre eine Ausnahme je Fehler. Erwartbare Fehler sind aber keine Ausnahmen, sondern Alltag: Das Netz ist weg, ein anderer war schneller, der Auftrag ist gelöscht. Ein Formular muss sie anzeigen können, ohne an jeder Stelle ein try zu haben, und der Compiler zwingt nicht zum Prüfen, aber ergebnis.Wert wirft, wenn Sie den Fehler übersehen haben.

C#
namespace Brandt.Auftragsbuch.Client;

public enum ApiFehlerArt
{
    NichtErreichbar,
    Zeitueberschreitung,
    NichtGefunden,
    Konflikt,
    Ungueltig,
    Serverfehler,
    Unerwartet,
}

public sealed record ApiFehler(ApiFehlerArt Art, string Meldung, IReadOnlyDictionary<string, string[]>? Felder = null);

// Erwartbare Fehler sind ein Ergebnis, keine Ausnahme: Ein Formular muss sie anzeigen können, ohne try/catch an jeder Stelle.
public sealed class ApiErgebnis<T>
{
    private readonly T? _wert;

    private ApiErgebnis(T? wert, ApiFehler? fehler)
    {
        _wert = wert;
        Fehler = fehler;
    }

    public ApiFehler? Fehler { get; }

    public bool IstOk => Fehler is null;

    public T Wert => IstOk ? _wert! : throw new InvalidOperationException(Fehler!.Meldung);

    public static ApiErgebnis<T> Erfolg(T wert) => new(wert, null);

    public static ApiErgebnis<T> Misserfolg(ApiFehler fehler) => new(default, fehler);
}

Der Client selbst:

C#
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Brandt.Auftragsbuch.Logik;
using Brandt.Auftragsbuch.Vertraege;
using Polly.Timeout;

namespace Brandt.Auftragsbuch.Client;

public interface IAuftragsApi
{
    Task<ApiErgebnis<AuftragsSeite>> LesenAsync(AuftragsStatus? status, int seite, int seitengroesse, CancellationToken ct = default);

    Task<ApiErgebnis<AuftragDetailDto>> EinzelnLesenAsync(int auftragId, CancellationToken ct = default);

    Task<ApiErgebnis<AuftragDetailDto>> AendernAsync(int auftragId, AuftragAenderungDto aenderung, CancellationToken ct = default);

    Task<ApiErgebnis<AbrechnungDto>> AbrechnenAsync(int auftragId, CancellationToken ct = default);
}

public sealed class AuftragsApiClient(HttpClient http) : IAuftragsApi
{
    private static readonly JsonSerializerOptions Json = VertragsJson.Neu();

    public Task<ApiErgebnis<AuftragsSeite>> LesenAsync(AuftragsStatus? status, int seite, int seitengroesse, CancellationToken ct = default)
    {
        string abfrage = $"seite={seite}&seitengroesse={seitengroesse}" + (status is { } gewaehlt ? $"&status={gewaehlt}" : string.Empty);
        return SendenAsync<AuftragsSeite>(() => http.GetAsync($"api/v1/auftraege?{abfrage}", ct), ct);
    }

    public Task<ApiErgebnis<AuftragDetailDto>> EinzelnLesenAsync(int auftragId, CancellationToken ct = default) =>
        SendenAsync<AuftragDetailDto>(() => http.GetAsync($"api/v1/auftraege/{auftragId}", ct), ct);

    public Task<ApiErgebnis<AuftragDetailDto>> AendernAsync(int auftragId, AuftragAenderungDto aenderung, CancellationToken ct = default) =>
        SendenAsync<AuftragDetailDto>(() => http.PutAsJsonAsync($"api/v1/auftraege/{auftragId}", aenderung, Json, ct), ct);

    public Task<ApiErgebnis<AbrechnungDto>> AbrechnenAsync(int auftragId, CancellationToken ct = default) =>
        SendenAsync<AbrechnungDto>(() => http.PostAsync($"api/v1/auftraege/{auftragId}/abrechnung", null, ct), ct);

    private static async Task<ApiErgebnis<T>> SendenAsync<T>(Func<Task<HttpResponseMessage>> senden, CancellationToken ct)
    {
        try
        {
            using HttpResponseMessage antwort = await senden();
            if (!antwort.IsSuccessStatusCode)
            {
                return ApiErgebnis<T>.Misserfolg(await FehlerLesenAsync(antwort, ct));
            }

            T? wert = await antwort.Content.ReadFromJsonAsync<T>(Json, ct);
            return wert is null
                ? ApiErgebnis<T>.Misserfolg(new ApiFehler(ApiFehlerArt.Unerwartet, "Die Antwort der API war leer."))
                : ApiErgebnis<T>.Erfolg(wert);
        }
        catch (TimeoutRejectedException)
        {
            // Die Zeitgrenze der Widerstandsfähigkeitsschicht (siehe Registrierung).
            return ApiErgebnis<T>.Misserfolg(new ApiFehler(ApiFehlerArt.Zeitueberschreitung, "Die API hat nicht rechtzeitig geantwortet."));
        }
        catch (TaskCanceledException) when (!ct.IsCancellationRequested)
        {
            // HttpClient.Timeout: ein TaskCanceledException, den nicht der Aufrufer ausgelöst hat.
            return ApiErgebnis<T>.Misserfolg(new ApiFehler(ApiFehlerArt.Zeitueberschreitung, "Die API hat nicht rechtzeitig geantwortet."));
        }
        catch (HttpRequestException ex)
        {
            return ApiErgebnis<T>.Misserfolg(new ApiFehler(ApiFehlerArt.NichtErreichbar, "Die API ist nicht erreichbar: " + ex.Message));
        }
        catch (JsonException)
        {
            return ApiErgebnis<T>.Misserfolg(new ApiFehler(ApiFehlerArt.Unerwartet, "Die Antwort der API ist nicht lesbar."));
        }
    }

    private static async Task<ApiFehler> FehlerLesenAsync(HttpResponseMessage antwort, CancellationToken ct)
    {
        ProblemAntwort? problem = null;
        try
        {
            problem = await antwort.Content.ReadFromJsonAsync<ProblemAntwort>(Json, ct);
        }
        catch (JsonException)
        {
            // Kein Problem-JSON, zum Beispiel die Fehlerseite eines Proxys: Es bleibt beim Statuscode.
        }

        string meldung = problem?.Title ?? $"Die API antwortete mit {(int)antwort.StatusCode}.";
        ApiFehlerArt art = antwort.StatusCode switch
        {
            HttpStatusCode.NotFound => ApiFehlerArt.NichtGefunden,
            HttpStatusCode.Conflict => ApiFehlerArt.Konflikt,
            HttpStatusCode.BadRequest or HttpStatusCode.UnprocessableEntity => ApiFehlerArt.Ungueltig,
            >= HttpStatusCode.InternalServerError => ApiFehlerArt.Serverfehler,
            _ => ApiFehlerArt.Unerwartet,
        };
        return new ApiFehler(art, meldung, problem?.Errors);
    }

    // Das Problem-JSON der API (RFC 9457), soweit der Client es braucht.
    private sealed record ProblemAntwort(string? Title, string? Detail, int? Status, Dictionary<string, string[]>? Errors);
}

Das catch am Ende von SendenAsync ist der wichtigste Teil. Es trennt, was Microsofts Dokumentation zu den Zeitgrenzen als getrennte Fälle beschreibt:

Ausnahme Wann Bedeutung für das Formular
HttpRequestException Verbindung abgelehnt, Name nicht gefunden NichtErreichbar: Netz oder Dienst prüfen
TaskCanceledException mit TimeoutException darin HttpClient.Timeout ist abgelaufen (Voreinstellung 100 Sekunden) Zeitueberschreitung
TimeoutRejectedException die Zeitgrenze der Standard-Widerstandsschicht ist abgelaufen Zeitueberschreitung
TaskCanceledException, den der Aufrufer selbst ausgelöst hat Benutzerin schließt das Fenster, ein neuer Aufruf löst den alten ab kein Fehler: der Abbruch wird durchgereicht

Zwei Stolperfallen stehen in der Tabelle. TimeoutRejectedException ist weder eine TimeoutException noch eine HttpRequestException; ein catch (HttpRequestException) fängt sie nicht. Und ein TaskCanceledException heißt nicht automatisch „Zeit abgelaufen“: Wer das Fenster schließt, bricht selbst ab, und dann soll keine Fehlermeldung erscheinen. Deshalb steht im catch die Bedingung when (!ct.IsCancellationRequested).