Fehlerbehandlung

Fehlschläge veröffentlichen bindbaren Fehlerzustand, führen Callbacks in definierter Reihenfolge aus und werden nur dann geworfen, wenn niemand sie beobachtet hat.

Bindbarer Fehlerzustand#

Bei einem Fehlschlag geht IsError an, und ErrorMessage trägt die Meldung. Auf dem gecachten Pfad bleiben vorhandene Daten sichtbar — die Komponente entscheidet, wie sie den Fehler daneben anzeigt. Ein erfolgreicher Refetch löscht den Fehlerzustand, Fehler-UI überlebt den Fehlschlag also nie.

DisplayUserException#

Die meisten Exception-Meldungen sind für Entwickler geschrieben. DisplayUserException ist die eine, deren DisplayMessage sich gefahrlos — und absichtlich — Endnutzern zeigen lässt. Wirf sie in deiner API-Schicht, dort, wo du weißt, was schiefgelaufen ist:

public async Task<List<Todo>> GetTodosAsync(CancellationToken token)
{
    var response = await _http.GetAsync("todos", token);

    if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
    {
        throw new DisplayUserException(
            message: "Todos are temporarily unavailable — try again in a minute.",
            internalMessage: $"GET /todos returned 503, correlation {response.Headers.GetCorrelationId()}");
    }

    return (await response.Content.ReadFromJsonAsync<List<Todo>>(token))!;
}

Deja leitet sie zusätzlich zu den allgemeinen Fehler-Callbacks an die dedizierten OnDisplayUserError- / OnDisplayUserErrorAsync-Callbacks weiter — eine Komponente kann so die freundliche Meldung rendern und die technische loggen, ohne selbst Exception-Typen zu prüfen.

Callback-Reihenfolge#

  1. OnDisplayUserErrorAsync, dann OnDisplayUserError — nur bei einer DisplayUserException;
  2. OnErrorAsync, dann OnError — bei jedem Fehlschlag;
  3. OnSettledAsync, dann OnSettled — nach Erfolg oder einem behandelten Fehlschlag, aber nicht nach einem Abbruch.

Der Vertrag für unbehandelte Fehlschläge#

Lief mindestens ein Fehler-Callback, gilt der Fehlschlag als behandelt und nichts propagiert weiter. Wurde keiner übergeben, wirft Execute eine InvalidOperationException, die die ursprüngliche Exception umschließt — ein Fehlschlag, den niemand beobachtet, darf nicht verschwinden. Da Execute typischerweise in OnInitializedAsync oder einem Event-Handler awaited wird, verdrahte mindestens OnError (oder binde IsError und übergib einen leeren Handler), damit ein vorübergehender Netzwerkfehler nicht zu einer unbehandelten Komponenten-Exception wird.

Abbruch ist kein Fehler#

Eine verdrängte oder beim Disposen abgebrochene Ausführung setzt keinen Fehlerzustand und führt keine Callbacks aus — nicht einmal settled. Der verdrängende Ladevorgang treibt das nächste Update.

Der Timeout-Retry#

Ein einziger, eng begrenzter eingebauter Retry: Schlägt ein Fetch mit der TaskCanceledException fehl, die eine innere TimeoutException trägt — die Signatur eines abgelaufenen HttpClient.Timeout, typischerweise ein Browser, der einen inaktiven Tab mitten im Request einfriert — und der eigene Token des Aufrufers ist nicht abgebrochen, wiederholt Deja den Fetch genau einmal. Queries sind idempotente Lesezugriffe; nach dem Aufwachen ist der Retry in Millisekunden fertig. Ein zweiter Timeout fällt in den normalen Fehlerpfad durch.

Live-Demo#

Freundliche vs. technische Fehlschläge
Query state IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1

#1 — delectus aut autem

A DisplayUserException is routed to OnDisplayUserError — its DisplayMessage is written for end users — and to the general OnError. A generic exception only reaches OnError. Because a callback observed the failure, nothing is thrown into the component lifecycle.

@inherits DejaComponentBase
@inject JsonPlaceholderApi Api
@inject FailureSwitch Failure

<div class="demo-toolbar">
    <button class="demo-button danger" @onclick="() => Failure.Arm(FailureSwitch.FailureKind.Generic)">
        @(Failure.Armed == FailureSwitch.FailureKind.Generic ? "Generic failure armed ✓" : "Arm generic failure")
    </button>
    <button class="demo-button danger" @onclick="() => Failure.Arm(FailureSwitch.FailureKind.DisplayUser)">
        @(Failure.Armed == FailureSwitch.FailureKind.DisplayUser ? "User-facing failure armed ✓" : "Arm user-facing failure")
    </button>
    <button class="demo-button primary" @onclick="Load">Fetch</button>
</div>

<StateInspector Query="_todo" Label="Query state"/>

@if (_userMessage is not null)
{
    <div class="demo-error">🙋 Shown to the user: @_userMessage</div>
}
@if (_technicalMessage is not null)
{
    <div class="demo-error">🛠 Logged for developers: @_technicalMessage</div>
}
@if (_todo.Data is { } todo && !_todo.IsError)
{
    <div class="demo-panel">
        <h4>#@todo.Id@todo.Title</h4>
    </div>
}

<p class="demo-note">
    A <code>DisplayUserException</code> is routed to <code>OnDisplayUserError</code> — its
    <code>DisplayMessage</code> is written for end users — <em>and</em> to the general
    <code>OnError</code>. A generic exception only reaches <code>OnError</code>. Because a
    callback observed the failure, nothing is thrown into the component lifecycle.
</p>

@code {
    private readonly Query<TodoDto> _todo = new();
    private string? _userMessage;
    private string? _technicalMessage;

    protected override Task OnInitializedAsync() => Load();

    private Task Load()
    {
        _userMessage = null;
        _technicalMessage = null;

        return _todo.Execute(token => Api.GetTodoAsync(1, token), p =>
        {
            p.OnDisplayUserError = e => _userMessage = e.DisplayMessage;
            p.OnError = e => _technicalMessage = e.Message;
        });
    }
}