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>