fix(frontend): add timeout and retry logic for official registry relays
rabbitprincess committed
Mar 29, 2026 at 19:38 UTC
ba278dceadee0a57f9409e09d2e49d893d04175a
1 file changed
+106
-21
frontend/src/components/ServerListView.tsx
+106
-21
@@ -27,7 +27,7 @@ type ListServer = ClientServer | AdminServer;
27
28
interface OfficialRegistryRelay {
29
url: string;
30
- status: "online" | "unreachable";
30
+ status: "online" | "disconnected" | "checking";
31
releaseVersion?: string;
32
}
33
@@ -44,12 +44,20 @@ const OFFICIAL_REGISTRY_SOURCE_URL =
44
const REPOSITORY_URL = "https://github.com/gosuda/portal";
45
46
async function loadOfficialRegistryRelay(
47
- relayURL: string
47
+ relayURL: string,
48
+ timeoutMs: number = 5000
49
): Promise<OfficialRegistryRelay> {
50
const domainURL = new URL(API_PATHS.sdk.domain, relayURL).toString();
51
52
+ const timeoutPromise = new Promise<never>((_, reject) => {
53
+ setTimeout(() => reject(new Error("timeout")), timeoutMs);
54
+ });
55
+
56
try {
52
- const domain = await apiClient.get<RelayDomainResponse>(domainURL);
57
+ const domain = await Promise.race([
58
+ apiClient.get<RelayDomainResponse>(domainURL),
59
+ timeoutPromise,
60
+ ]);
61
return {
62
url: relayURL,
63
status: "online",
@@ -61,15 +69,13 @@ async function loadOfficialRegistryRelay(
69
} catch {
70
return {
71
url: relayURL,
64
- status: "unreachable",
72
+ status: "disconnected",
73
releaseVersion: "",
74
};
75
}
76
}
77
70
-async function loadOfficialRegistryRelays(
71
- sourceURL: string
72
-): Promise<OfficialRegistryRelay[]> {
78
+async function loadOfficialRegistryRelayURLs(sourceURL: string): Promise<string[]> {
79
const response = await fetch(sourceURL, {
80
headers: { Accept: "application/json" },
81
});
@@ -85,11 +91,46 @@ async function loadOfficialRegistryRelays(
91
)
92
: [];
93
88
- return Promise.all(
89
- relayURLs.map((relayURL) => loadOfficialRegistryRelay(relayURL.trim()))
94
+ return relayURLs.map((relayURL) => relayURL.trim());
95
+}
96
+
97
+function replaceOfficialRegistryRelay(
98
+ currentRelays: OfficialRegistryRelay[] | null,
99
+ nextRelay: OfficialRegistryRelay
100
+): OfficialRegistryRelay[] | null {
101
+ if (!currentRelays) {
102
+ return currentRelays;
103
+ }
104
+
105
+ return currentRelays.map((relay) =>
106
+ relay.url === nextRelay.url ? nextRelay : relay
107
);
108
}
109
110
+async function retryDisconnectedRelays(
111
+ currentRelays: OfficialRegistryRelay[]
112
+): Promise<OfficialRegistryRelay[]> {
113
+ const disconnectedRelays = currentRelays.filter(
114
+ (relay) => relay.status === "disconnected"
115
+ );
116
+
117
+ if (disconnectedRelays.length === 0) {
118
+ return currentRelays;
119
+ }
120
+
121
+ const retriedResults = await Promise.all(
122
+ disconnectedRelays.map((relay) =>
123
+ loadOfficialRegistryRelay(relay.url, 5000)
124
+ )
125
+ );
126
+
127
+ const resultMap = new Map<string, OfficialRegistryRelay>();
128
+ currentRelays.forEach((relay) => resultMap.set(relay.url, relay));
129
+ retriedResults.forEach((relay) => resultMap.set(relay.url, relay));
130
+
131
+ return Array.from(resultMap.values());
132
+}
133
+
134
interface ServerListViewProps {
135
title?: string;
136
searchQuery: string;
@@ -261,10 +302,26 @@ export function ServerListView({
302
let cancelled = false;
303
setOfficialRegistryRelays(null);
304
264
- void loadOfficialRegistryRelays(OFFICIAL_REGISTRY_SOURCE_URL)
265
- .then((relays) => {
305
+ void loadOfficialRegistryRelayURLs(OFFICIAL_REGISTRY_SOURCE_URL)
306
+ .then((relayURLs) => {
307
if (!cancelled) {
267
- setOfficialRegistryRelays(relays);
308
+ setOfficialRegistryRelays(
309
+ relayURLs.map((relayURL) => ({
310
+ url: relayURL,
311
+ status: "checking",
312
+ releaseVersion: "",
313
+ }))
314
+ );
315
+
316
+ relayURLs.forEach((relayURL) => {
317
+ void loadOfficialRegistryRelay(relayURL).then((relay) => {
318
+ if (!cancelled) {
319
+ setOfficialRegistryRelays((currentRelays) =>
320
+ replaceOfficialRegistryRelay(currentRelays, relay)
321
+ );
322
+ }
323
+ });
324
+ });
325
}
326
})
327
.catch((error) => {
@@ -279,6 +336,34 @@ export function ServerListView({
336
};
337
}, [isAdmin]);
338
339
+ useEffect(() => {
340
+ if (isAdmin || !officialRegistryRelays) {
341
+ return;
342
+ }
343
+
344
+ const hasDisconnected = officialRegistryRelays.some(
345
+ (relay) => relay.status === "disconnected"
346
+ );
347
+
348
+ if (!hasDisconnected) {
349
+ return;
350
+ }
351
+
352
+ const intervalId = setInterval(() => {
353
+ void retryDisconnectedRelays(officialRegistryRelays)
354
+ .then((updatedRelays) => {
355
+ setOfficialRegistryRelays(updatedRelays);
356
+ })
357
+ .catch((error) => {
358
+ console.error("Failed to retry disconnected relays", error);
359
+ });
360
+ }, 30000);
361
+
362
+ return () => {
363
+ clearInterval(intervalId);
364
+ };
365
+ }, [isAdmin, officialRegistryRelays]);
366
+
367
const isAllSelected =
368
allLeaseIds.length > 0 &&
369
allLeaseIds.every((id) => selectedLeaseIds.has(id));
@@ -722,11 +807,7 @@ export function ServerListView({
807
</div>
808
809
<div className="mt-6 rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
725
- {officialRegistryRelays === null ? (
726
- <p className="text-sm text-text-muted">
727
- Loading official registry...
728
- </p>
729
- ) : officialRegistryAvailable ? (
810
+ {officialRegistryAvailable ? (
811
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
812
{officialRegistryRelays.map((relay) => {
813
return (
@@ -743,9 +824,13 @@ export function ServerListView({
824
{relay.url}
825
</a>
826
<div className="flex shrink-0 flex-wrap items-center gap-2">
746
- {relay.status === "unreachable" ? (
827
+ {relay.status === "checking" ? (
828
+ <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
829
+ Checking
830
+ </span>
831
+ ) : relay.status === "disconnected" ? (
832
<span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
748
- Offline
833
+ Disconnected
834
</span>
835
) : relay.releaseVersion ? (
836
<span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
@@ -757,11 +842,11 @@ export function ServerListView({
842
);
843
})}
844
</div>
760
- ) : (
845
+ ) : officialRegistryRelays !== null ? (
846
<p className="text-sm text-text-muted">
847
Registry entries are unavailable right now.
848
</p>
764
- )}
849
+ ) : null}
850
</div>
851
</section>
852
</main>