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#
OnDisplayUserErrorAsync, dannOnDisplayUserError— nur bei einerDisplayUserException;OnErrorAsync, dannOnError— bei jedem Fehlschlag;OnSettledAsync, dannOnSettled— 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#
#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;
});
}
}