Skip to content

Crash: WebDataAccessRequestException: Web data access call to '/data/get-changed-entities' failed with 404: #1326

Description

@JoshuaRowePhantom

Crash: WebDataAccessRequestException for /data/get-changed-entities 404 escapes background refresh

Summary

The background refresh timer in MainWindowViewModel posts an async void handler (OnRefreshTick) that awaits EntityBroker.RefreshAsync(). When the current dev-tunnel/web-data endpoint answers /data/get-changed-entities with HTTP 404, WebClientDataAccessLayer.PostAsync turns that into a WebDataAccessRequestException. ReconnectingWebDataAccessLayer.ExecuteAsync does not classify a 404 as connectivity-related (only null/401/>=500 are), so the exception is not retried or reconnected — it propagates out of RefreshAsync, out of the async void handler, is posted onto the Avalonia dispatcher via Task.ThrowAsync, and is picked up by UnhandledExceptionHandler.OnDispatcherUnhandledException which crashes the app with the crash dialog.

The desired behaviour (owner comment on #1326): "Failing to connect to mongo queries should result in the network status icon having an overlayed exclamation mark and should have a section in the dialog box showing the most recent errors."

Fix must follow the project convention (see #1301/#1303/#1322): NO central benign-exception filtering. Handle the failure at the source sites of relevance — the background refresh handler, the reconnect-classification layer, and the connection-status view model that already drives the network icon's warning glyph.

Observed stack:

Phantom.Workspaces.Data.Web.Client.WebDataAccessRequestException: Web data access call to '/data/get-changed-entities' failed with 404:
   at Phantom.Workspaces.Data.Web.Client.WebClientDataAccessLayer.PostAsync[TRequest,TResponse](...)
   at Phantom.Workspaces.Services.DevTunnel.ReconnectingWebDataAccessLayer.ExecuteAsync[TResult](...)
   at Phantom.Workspaces.EntityBroker.RefreshAsync(CancellationToken cancellationToken)
   at Phantom.Workspaces.ViewModels.MainWindowViewModel.OnRefreshTick(Object sender, EventArgs e)
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_0(Object state)
   at Avalonia.Threading.SendOrPostCallbackDispatcherOperation.InvokeCore()

Root Cause

1. OnRefreshTick is an unguarded async void fire-and-forget handler

features/Phantom.Workspaces/ViewModels/MainWindowViewModel.cs:171 wires a 3-second DispatcherTimer to OnRefreshTick:

// MainWindowViewModel.cs:171–172
this.refreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
this.refreshTimer.Tick += this.OnRefreshTick;

The handler itself has no error handling at all:

// MainWindowViewModel.cs:1764–1769
private async void OnRefreshTick(
    object? sender,
    EventArgs e)
{
    await this.EntityBroker.RefreshAsync();
}

Any exception from RefreshAsync escapes the async void state machine → Task.ThrowAsync posts it to the dispatcher → UnhandledExceptionHandler.OnDispatcherUnhandledException fires the crash dialog. This is the direct crash cause: a transient/misrouted background poll becomes a fatal user-facing crash.

2. WebClientDataAccessLayer.PostAsync throws on any non-success HTTP status

features/Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.cs:136–142:

if (!response.IsSuccessStatusCode)
{
    var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
    throw new WebDataAccessRequestException(
        $"Web data access call to '{relativeUri}' failed with {(int)response.StatusCode}: {errorBody}",
        response.StatusCode);
}

A 404 for /data/get-changed-entities in practice means the web-data endpoint is not (yet) available on the currently-resolved dev-tunnel host (wrong/stale relay, an old build that doesn't serve that route, the host process not yet up). It is a connectivity/availability failure, not an application error.

3. ReconnectingWebDataAccessLayer does not classify 404 as a reconnectable failure

features/Phantom.Workspaces.Data.Web.Client/WebDataAccessRequestException.cs:28:

public bool IsConnectivityFailure =>
    this.StatusCode is null
    || this.StatusCode == HttpStatusCode.Unauthorized
    || (int)this.StatusCode >= 500;

features/Phantom.Workspaces/Services/DevTunnel/ReconnectingWebDataAccessLayer.cs:154–155:

private static bool DefaultIsConnectionFailure(Exception exception)
    => exception is WebDataAccessRequestException { IsConnectivityFailure: true };

And in ExecuteAsync (line 102–123), only failures matching isConnectionFailure trigger reconnect/retry; everything else propagates:

catch (Exception exception) when (exception is not OperationCanceledException && this.isConnectionFailure(exception))
{
    await this.ReconnectAsync(exception, cancellationToken).ConfigureAwait(false);
    ...
}

So a 404 sails straight through the reconnect layer and out of EntityBroker.RefreshAsync (features/Phantom.Workspaces/EntityBroker.cs:341), where the call is issued via Task.Run(() => this.entityRepository.DataAccessLayer.GetChangedEntitiesAsync(...)).

4. UnhandledExceptionHandler makes it fatal

features/Phantom.Workspaces/UnhandledExceptionHandler.cs:36–42:

internal static void OnDispatcherUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
{
    Services.Logging.GlobalExceptionLogging.OnDispatcherUnhandled(e.Exception);
    e.Handled = true;
    ShowOrDiscard(e.Exception, isTerminating: false);
}

This is intentional as a last resort. Per project convention we do not add a filter here for WebDataAccessRequestException; we fix the sources.

5. ConnectionStatusViewModel already drives the network-icon warning glyph

features/Phantom.Workspaces/ConnectionStatusWindow.axaml / features/Phantom.Workspaces/MainWindow.axaml:68–82 bind the top-right network icon's warning overlay to ConnectionStatus.HasProblem / ConnectionStatus.ProblemText:

IsVisible="{Binding ConnectionStatus.HasProblem, FallbackValue=False}"
ToolTip.Tip="{Binding ConnectionStatus.ProblemText}"

Today HasProblem is driven only by DevTunnelHostState.Error / Reconnecting set through SetDevTunnelStatus (ConnectionStatusViewModel.cs:138–159). There is no notion of a recent-errors list and no channel for client-side connectivity errors (like the 404 from refresh). This is exactly the surface the owner comment refers to.

Affected Files

File Role
features/Phantom.Workspaces/ViewModels/MainWindowViewModel.cs (l. 1764–1769; l. 171–172) OnRefreshTick async-void source site that must handle refresh failures instead of letting them crash.
features/Phantom.Workspaces/EntityBroker.cs (l. 327–380) RefreshAsync — where GetChangedEntitiesAsync is issued; may also be a natural place to surface the connectivity failure.
features/Phantom.Workspaces/Services/DevTunnel/ReconnectingWebDataAccessLayer.cs (l. 102–123, 154–155) Reconnect classification — should treat 404/service-unavailable as transient endpoint-availability failure.
features/Phantom.Workspaces.Data.Web.Client/WebDataAccessRequestException.cs (l. 28) IsConnectivityFailure classification predicate.
features/Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.cs (l. 136–142) Where the 404 becomes WebDataAccessRequestException.
features/Phantom.Workspaces/ViewModels/ConnectionStatusViewModel.cs (l. 122–159) Network-status VM that owns HasProblem / ProblemText; must gain a recent-errors list and a "record client connectivity error" entry point.
features/Phantom.Workspaces/ConnectionStatusWindow.axaml Add a "Recent errors" section bound to the new list.
features/Phantom.Workspaces/MainWindow.axaml (l. 68–82) Warning overlay binding — already wired to HasProblem; HasProblem must also reflect the new recent-errors state.
features/Phantom.Workspaces/UnhandledExceptionHandler.cs Not modified. Do not add a central benign-exception filter here.

Design / Fix (at-source; NO central filtering)

Three surgical, at-source fixes:

A. OnRefreshTick — catch and route to the connection-status VM

Refresh is a background, best-effort poll. It must never crash the app; failures should be recorded on the network-status VM and (optionally) logged.

// MainWindowViewModel.cs — at-source handling in the async void timer handler.
private async void OnRefreshTick(
    object? sender,
    EventArgs e)
{
    try
    {
        await this.EntityBroker.RefreshAsync();
    }
    catch (OperationCanceledException)
    {
        // Refresh was cancelled (shutdown / cancellation) — nothing to report.
    }
    catch (WebDataAccessRequestException exception)
    {
        // Transient/misrouted web-data call: surface as a connectivity problem
        // on the network status icon (exclamation overlay) and record for the
        // "recent errors" section of the connection-status dialog.
        this.ConnectionStatus?.RecordClientConnectivityError(exception);
    }
}

We deliberately catch only OperationCanceledException and WebDataAccessRequestException here — any other exception is a real bug and should still flow through the unhandled handler.

B. ReconnectingWebDataAccessLayer — treat endpoint-missing as transient

Extend WebDataAccessRequestException.IsConnectivityFailure (or the DefaultIsConnectionFailure predicate in ReconnectingWebDataAccessLayer) so that 404 and 503 are also treated as reconnectable. Rationale: a 404 for a known data endpoint like /data/get-changed-entities is not an application error but an availability problem (wrong/stale relay, host not serving the route yet). Retrying against a freshly resolved tunnel is the correct behaviour.

// WebDataAccessRequestException.cs
public bool IsConnectivityFailure =>
    this.StatusCode is null
    || this.StatusCode == HttpStatusCode.Unauthorized
    || this.StatusCode == HttpStatusCode.NotFound
    || this.StatusCode == HttpStatusCode.ServiceUnavailable
    || (int)this.StatusCode >= 500;

With this, ReconnectingWebDataAccessLayer.ExecuteAsync (l. 102–123) automatically re-resolves the tunnel and retries; the caller only sees the exception if the reconnect eventually fails (Failed status), at which point (A) surfaces it to the status VM instead of crashing.

C. ConnectionStatusViewModel — add a recent-errors list and expose it in the dialog

Extend the existing status VM so that HasProblem / ProblemText also reflect client-side connectivity errors, and add a bounded ring buffer of recent errors that the connection-status dialog can list.

// ConnectionStatusViewModel.cs additions (sketch)
private readonly ObservableCollection<RecentConnectivityErrorViewModel> recentErrors = new();
public ReadOnlyObservableCollection<RecentConnectivityErrorViewModel> RecentErrors { get; }

public bool HasProblem =>
    this.devTunnelState is DevTunnelHostState.Error or DevTunnelHostState.Reconnecting
    || this.recentErrors.Count > 0;

public string? ProblemText => this.HasProblem
    ? this.devTunnelError
        ?? this.recentErrors.LastOrDefault()?.Message
        ?? this.DevTunnelStatusText
    : null;

public void RecordClientConnectivityError(Exception exception)
{
    this.dispatch(() =>
    {
        this.recentErrors.Insert(0, new RecentConnectivityErrorViewModel(DateTimeOffset.Now, exception.Message));
        while (this.recentErrors.Count > MaxRecentErrors) this.recentErrors.RemoveAt(this.recentErrors.Count - 1);
        this.RaisePropertyChanged(nameof(this.HasProblem));
        this.RaisePropertyChanged(nameof(this.ProblemText));
    });
}

Add a "Recent errors" section to ConnectionStatusWindow.axaml bound to RecentErrors.

Why not a global catch?

Per project convention (#1301, #1303, #1322) we do not filter WebDataAccessRequestException centrally in UnhandledExceptionHandler. Doing so would silently swallow the same exception when it arises in unexpected call sites. Instead the fix is scoped precisely to:

  • the fire-and-forget refresh timer that produced this crash,
  • the reconnect-classification layer whose job is to know which HTTP failures are transient, and
  • the network-status VM which already owns the warning glyph.

Expected Tests

New tests, following Subject_Scenario_ExpectedOutcome PascalCase in the existing Phantom.Workspaces.Tests files:

Test File Purpose
MainWindowViewModel_OnRefreshTick_WhenWebDataThrows_DoesNotCrashAndFlagsNetworkStatus MainWindowViewModelTests.cs Fire a refresh tick where EntityBroker.RefreshAsync throws WebDataAccessRequestException; assert no exception escapes, ConnectionStatus.HasProblem == true, and the error appears in RecentErrors.
MainWindowViewModel_OnRefreshTick_WhenUnexpectedExceptionThrows_StillPropagates MainWindowViewModelTests.cs Guard against over-broad swallowing: a non-connectivity exception must still surface.
ReconnectingWebDataAccessLayer_When404_ClassifiesAsTransientDisconnect ReconnectingWebDataAccessLayerTests.cs 404 response triggers reconnect/retry, mirroring the existing Operation_On401_TriggersReconnectAndRetries shape.
ReconnectingWebDataAccessLayer_When503_ClassifiesAsTransientDisconnect ReconnectingWebDataAccessLayerTests.cs Sibling coverage for 503.
WebDataAccessRequestException_IsConnectivityFailure_Covers404And503 Phantom.Workspaces.Data.Web.Client.Tests Directly asserts predicate.
ConnectionStatusViewModel_RecordClientConnectivityError_AppendsToRecentErrorsAndFlagsProblem ConnectionStatusViewModelTests.cs Records error, HasProblem/ProblemText reflect it, RecentErrors exposes it.
ConnectionStatusViewModel_RecentErrors_AreBoundedAndOrderedNewestFirst ConnectionStatusViewModelTests.cs Ring-buffer behaviour, ordering for the dialog.

Considered / Background

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiednext-upverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions