Error handling

Failures publish bindable error state, run callbacks in a defined order, and are only thrown when nobody observed them.

Bindable error state#

On failure, IsError turns on and ErrorMessage carries the message. On the cached path, existing data stays on screen — the component decides how to surface the error next to it. A successful refetch clears the error state, so error UI never outlives the failure.

DisplayUserException#

Most exception messages are written for developers. DisplayUserException is the one whose DisplayMessage is safe — and intended — to show end users. Throw it in your API layer where you know what went wrong:

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 routes it to the dedicated OnDisplayUserError / OnDisplayUserErrorAsync callbacks in addition to the general error callbacks, so a component can render the friendly message and log the technical one without type-checking exceptions itself.

Callback order#

  1. OnDisplayUserErrorAsync, then OnDisplayUserError — only for a DisplayUserException;
  2. OnErrorAsync, then OnError — for every failure;
  3. OnSettledAsync, then OnSettled — after success or a handled failure, but not after cancellation.

The unhandled-failure contract#

If at least one error callback ran, the failure is considered handled and nothing propagates. If none was supplied, Execute throws an InvalidOperationException wrapping the original exception — a failure nobody observes must not vanish. Since Execute is typically awaited from OnInitializedAsync or an event handler, wire at least OnError (or bind IsError and pass an empty handler) to keep a transient network error from becoming an unhandled component exception.

Cancellation is not an error#

A superseded or disposed execution sets no error state and runs no callbacks — not even settled. The superseding load drives the next update.

The timeout retry#

One narrow, built-in retry: when a fetch fails with the TaskCanceledException that carries an inner TimeoutException — the signature of HttpClient.Timeout expiry, typically a browser freezing an inactive tab mid-request — and the caller's own token is not cancelled, Deja retries the fetch once. Queries are idempotent reads; on resume the retry completes in milliseconds. A second timeout falls through to the normal error path.

Live demo#

Friendly vs technical failures
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;
        });
    }
}