Skip to content

Commit 8c4ddf4

Browse files
committed
feat(backend): implement fetch interceptor for backend connection monitoring
Signed-off-by: Jan-Hendrik Spahn <jan-hendrik.spahn@soptim.de>
1 parent c00cdb0 commit 8c4ddf4

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright (c) 2024-2026 SOPTIM AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
/**
19+
* Backend connection monitor.
20+
*
21+
* Installs a wrapper around `window.fetch` that detects network-level
22+
* failures (TypeError from `fetch`) on requests to the backend and surfaces
23+
* them as a single sticky toast. HTTP error responses (4xx / 5xx) are NOT
24+
* intercepted — those are application-level errors and remain the
25+
* responsibility of the individual call site.
26+
*
27+
* On the first request that succeeds after an offline period the sticky toast
28+
* is dismissed and a short "connection restored" toast is shown.
29+
*/
30+
31+
import { PUBLIC_BACKEND_URL } from "$lib/config/runtime";
32+
import { toastStore } from "$lib/eventhandling/toastStore.svelte.js";
33+
34+
/** Reactive connection state — components can read `.isOffline` if needed. */
35+
export const backendConnection = $state({ isOffline: false });
36+
37+
let offlineToastId = null;
38+
let installed = false;
39+
40+
/**
41+
* Resolve the URL of a `fetch()` first argument to a string we can pattern-match.
42+
* `fetch` accepts string | URL | Request, so we have to handle all three.
43+
*/
44+
function resolveUrl(input) {
45+
if (typeof input === "string") return input;
46+
if (input instanceof URL) return input.href;
47+
if (typeof Request !== "undefined" && input instanceof Request) {
48+
return input.url;
49+
}
50+
return "";
51+
}
52+
53+
function isBackendUrl(url) {
54+
if (!PUBLIC_BACKEND_URL) return false;
55+
return url.startsWith(PUBLIC_BACKEND_URL);
56+
}
57+
58+
function isNetworkError(error) {
59+
if (!error) return false;
60+
if (error.name === "AbortError") return false;
61+
return error instanceof TypeError;
62+
}
63+
64+
function markOffline() {
65+
backendConnection.isOffline = true;
66+
if (offlineToastId !== null) return;
67+
offlineToastId = toastStore.error(
68+
"Backend unreachable",
69+
"Could not reach the backend. Check that the server is running and try again.",
70+
{ duration: 0 },
71+
);
72+
}
73+
74+
function markOnline() {
75+
if (!backendConnection.isOffline) return;
76+
backendConnection.isOffline = false;
77+
if (offlineToastId !== null) {
78+
toastStore.dismiss(offlineToastId);
79+
offlineToastId = null;
80+
}
81+
toastStore.success("Backend connection restored", "The connection to the backend has been restored.");
82+
}
83+
84+
/**
85+
* Install the global fetch interceptor. Safe to call multiple times.
86+
* Returns an `uninstall` function.
87+
*/
88+
export function installBackendFetchInterceptor() {
89+
if (typeof window === "undefined") return () => {};
90+
if (installed) return () => {};
91+
installed = true;
92+
93+
const originalFetch = window.fetch.bind(window);
94+
95+
window.fetch = async function interceptedFetch(input, init) {
96+
const url = resolveUrl(input);
97+
const watched = isBackendUrl(url);
98+
99+
try {
100+
const response = await originalFetch(input, init);
101+
if (watched) {
102+
markOnline();
103+
}
104+
return response;
105+
} catch (error) {
106+
if (watched && isNetworkError(error)) {
107+
markOffline();
108+
}
109+
throw error;
110+
}
111+
};
112+
113+
return function uninstall() {
114+
if (!installed) return;
115+
window.fetch = originalFetch;
116+
installed = false;
117+
};
118+
}
119+
120+
/**
121+
* Active probe — call this on app start so a cold-start with the backend
122+
* already down surfaces the toast before the user clicks anything.
123+
*/
124+
export async function probeBackendConnection() {
125+
if (!PUBLIC_BACKEND_URL || typeof window === "undefined") return;
126+
try {
127+
await fetch(`${PUBLIC_BACKEND_URL}/datasets`, {
128+
method: "GET",
129+
credentials: "include",
130+
});
131+
} catch {
132+
// Ignore errors here; the interceptor will mark offline if it's a network error.
133+
}
134+
}

frontend/src/routes/+layout.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
fetchCanRedo,
2727
} from "$lib/actions/versionControlActions.js";
2828
import { BackendConnection } from "$lib/api/backend.js";
29+
import {
30+
installBackendFetchInterceptor,
31+
probeBackendConnection,
32+
} from "$lib/api/backendConnectionMonitor.svelte.js";
2933
import { Menubar } from "$lib/components/bitsui/menubar";
3034
import BrandLogo from "$lib/components/BrandLogo.svelte";
3135
import ButtonControl from "$lib/components/ButtonControl.svelte";
@@ -73,6 +77,8 @@
7377
});
7478
7579
onMount(() => {
80+
installBackendFetchInterceptor();
81+
probeBackendConnection();
7682
loadSnapshot();
7783
});
7884

0 commit comments

Comments
 (0)