diff --git a/apps/frontend/src/composables/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts index 8c79cc62d..8d07648d5 100644 --- a/apps/frontend/src/composables/servers/modrinth-servers.ts +++ b/apps/frontend/src/composables/servers/modrinth-servers.ts @@ -102,7 +102,7 @@ export class ModrinthServer { try { const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, { override: auth, - retry: false, + retry: 1, // Reduce retries for optional resources }); if (fileData instanceof Blob && import.meta.client) { @@ -124,64 +124,114 @@ export class ModrinthServer { return dataURL; } } catch (error) { - if (error instanceof ModrinthServerError && error.statusCode === 404 && iconUrl) { - // Handle external icon processing - try { - const response = await fetch(iconUrl); - if (!response.ok) throw new Error("Failed to fetch icon"); - const file = await response.blob(); - const originalFile = new File([file], "server-icon-original.png", { - type: "image/png", - }); - - if (import.meta.client) { - const dataURL = await new Promise((resolve) => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - const img = new Image(); - img.onload = () => { - canvas.width = 64; - canvas.height = 64; - ctx?.drawImage(img, 0, 0, 64, 64); - canvas.toBlob(async (blob) => { - if (blob) { - const scaledFile = new File([blob], "server-icon.png", { type: "image/png" }); - await useServersFetch(`/create?path=/server-icon.png&type=file`, { - method: "POST", - contentType: "application/octet-stream", - body: scaledFile, - override: auth, - }); - await useServersFetch(`/create?path=/server-icon-original.png&type=file`, { - method: "POST", - contentType: "application/octet-stream", - body: originalFile, - override: auth, - }); - } - }, "image/png"); - const dataURL = canvas.toDataURL("image/png"); - sharedImage.value = dataURL; - resolve(dataURL); - URL.revokeObjectURL(img.src); - }; - img.src = URL.createObjectURL(file); - }); - return dataURL; - } - } catch (error) { - console.error("Failed to process external icon:", error); + if (error instanceof ModrinthServerError) { + if (error.statusCode && error.statusCode >= 500) { + console.debug("Service unavailable, skipping icon processing"); + sharedImage.value = undefined; + return undefined; } + + if (error.statusCode === 404 && iconUrl) { + try { + const response = await fetch(iconUrl); + if (!response.ok) throw new Error("Failed to fetch icon"); + const file = await response.blob(); + const originalFile = new File([file], "server-icon-original.png", { + type: "image/png", + }); + + if (import.meta.client) { + const dataURL = await new Promise((resolve) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + img.onload = () => { + canvas.width = 64; + canvas.height = 64; + ctx?.drawImage(img, 0, 0, 64, 64); + canvas.toBlob(async (blob) => { + if (blob) { + const scaledFile = new File([blob], "server-icon.png", { + type: "image/png", + }); + await useServersFetch(`/create?path=/server-icon.png&type=file`, { + method: "POST", + contentType: "application/octet-stream", + body: scaledFile, + override: auth, + }); + await useServersFetch(`/create?path=/server-icon-original.png&type=file`, { + method: "POST", + contentType: "application/octet-stream", + body: originalFile, + override: auth, + }); + } + }, "image/png"); + const dataURL = canvas.toDataURL("image/png"); + sharedImage.value = dataURL; + resolve(dataURL); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(file); + }); + return dataURL; + } + } catch (externalError: any) { + console.debug("Could not process external icon:", externalError.message); + } + } + } else { + throw error; } } - } catch (error) { - console.error("Failed to process server icon:", error); + } catch (error: any) { + console.debug("Icon processing failed:", error.message); } sharedImage.value = undefined; return undefined; } + async testNodeReachability(): Promise { + if (!this.general?.datacenter) { + console.warn("No datacenter info available for ping test"); + return false; + } + + const datacenter = this.general.datacenter; + const wsUrl = `wss://${datacenter}.nodes.modrinth.com/pingtest`; + + try { + return await new Promise((resolve) => { + const socket = new WebSocket(wsUrl); + const timeout = setTimeout(() => { + socket.close(); + resolve(false); + }, 5000); + + socket.onopen = () => { + clearTimeout(timeout); + socket.send(performance.now().toString()); + }; + + socket.onmessage = () => { + clearTimeout(timeout); + socket.close(); + resolve(true); + }; + + socket.onerror = () => { + clearTimeout(timeout); + resolve(false); + }; + }); + } catch (error) { + console.error(`Failed to ping node ${wsUrl}:`, error); + return false; + } + } + async refresh( modules: ModuleName[] = [], options?: { @@ -195,6 +245,8 @@ export class ModrinthServer { : (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]); for (const module of modulesToRefresh) { + this.errors[module] = undefined; + try { switch (module) { case "general": { @@ -239,6 +291,18 @@ export class ModrinthServer { break; } } catch (error) { + if (error instanceof ModrinthServerError) { + if (error.statusCode === 404 && ["fs", "content"].includes(module)) { + console.debug(`Optional ${module} resource not found:`, error.message); + continue; + } + + if (error.statusCode && error.statusCode >= 500) { + console.debug(`Temporary ${module} unavailable:`, error.message); + continue; + } + } + this.errors[module] = { error: error instanceof ModrinthServerError diff --git a/apps/frontend/src/composables/servers/modules/fs.ts b/apps/frontend/src/composables/servers/modules/fs.ts index 1072789e0..39fe75db6 100644 --- a/apps/frontend/src/composables/servers/modules/fs.ts +++ b/apps/frontend/src/composables/servers/modules/fs.ts @@ -22,26 +22,49 @@ export class FSModule extends ServerModule { this.opsQueuedForModification = []; } - private async retryWithAuth(requestFn: () => Promise): Promise { + private async retryWithAuth( + requestFn: () => Promise, + ignoreFailure: boolean = false, + ): Promise { try { return await requestFn(); } catch (error) { if (error instanceof ModrinthServerError && error.statusCode === 401) { + console.debug("Auth failed, refreshing JWT and retrying"); await this.fetch(); // Refresh auth return await requestFn(); } + + const available = await this.server.testNodeReachability(); + if (!available && !ignoreFailure) { + this.server.moduleErrors.general = { + error: new ModrinthServerError( + "Unable to reach node. FS operation failed and subsequent ping test failed.", + 500, + error as Error, + "fs", + ), + timestamp: Date.now(), + }; + } + throw error; } } - listDirContents(path: string, page: number, pageSize: number): Promise { + listDirContents( + path: string, + page: number, + pageSize: number, + ignoreFailure: boolean = false, + ): Promise { return this.retryWithAuth(async () => { const encodedPath = encodeURIComponent(path); return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, { override: this.auth, retry: false, }); - }); + }, ignoreFailure); } createFileOrFolder(path: string, type: "file" | "directory"): Promise { @@ -150,7 +173,7 @@ export class FSModule extends ServerModule { }); } - downloadFile(path: string, raw?: boolean): Promise { + downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise { return this.retryWithAuth(async () => { const encodedPath = encodeURIComponent(path); const fileData = await useServersFetch(`/download?path=${encodedPath}`, { @@ -161,7 +184,7 @@ export class FSModule extends ServerModule { return raw ? fileData : await fileData.text(); } return fileData; - }); + }, ignoreFailure); } extractFile( diff --git a/apps/frontend/src/composables/servers/modules/general.ts b/apps/frontend/src/composables/servers/modules/general.ts index 9e8a4c4cd..e46e62b4e 100644 --- a/apps/frontend/src/composables/servers/modules/general.ts +++ b/apps/frontend/src/composables/servers/modules/general.ts @@ -46,13 +46,18 @@ export class GeneralModule extends ServerModule implements ServerGeneral { data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined; } - const motd = await this.getMotd(); - if (motd === "A Minecraft Server") { - await this.setMotd( - `§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`, - ); + try { + const motd = await this.getMotd(); + if (motd === "A Minecraft Server") { + await this.setMotd( + `§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`, + ); + } + data.motd = motd; + } catch { + console.error("[Modrinth Servers] [General] Failed to fetch MOTD."); + data.motd = undefined; } - data.motd = motd; // Copy data to this module Object.assign(this, data); @@ -178,7 +183,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral { async getMotd(): Promise { try { - const props = await this.server.fs.downloadFile("/server.properties"); + const props = await this.server.fs.downloadFile("/server.properties", false, true); if (props) { const lines = props.split("\n"); for (const line of lines) { @@ -194,19 +199,25 @@ export class GeneralModule extends ServerModule implements ServerGeneral { } async setMotd(motd: string): Promise { - const props = (await this.server.fetchConfigFile("ServerProperties")) as any; - if (props) { - props.motd = motd; - const newProps = this.server.constructServerProperties(props); - const octetStream = new Blob([newProps], { type: "application/octet-stream" }); - const auth = await useServersFetch(`servers/${this.serverId}/fs`); + try { + const props = (await this.server.fetchConfigFile("ServerProperties")) as any; + if (props) { + props.motd = motd; + const newProps = this.server.constructServerProperties(props); + const octetStream = new Blob([newProps], { type: "application/octet-stream" }); + const auth = await useServersFetch(`servers/${this.serverId}/fs`); - await useServersFetch(`/update?path=/server.properties`, { - method: "PUT", - contentType: "application/octet-stream", - body: octetStream, - override: auth, - }); + await useServersFetch(`/update?path=/server.properties`, { + method: "PUT", + contentType: "application/octet-stream", + body: octetStream, + override: auth, + }); + } + } catch { + console.error( + "[Modrinth Servers] [General] Failed to set MOTD due to lack of server properties file.", + ); } } } diff --git a/apps/frontend/src/composables/servers/servers-fetch.ts b/apps/frontend/src/composables/servers/servers-fetch.ts index cd403fb1e..5b5d925b1 100644 --- a/apps/frontend/src/composables/servers/servers-fetch.ts +++ b/apps/frontend/src/composables/servers/servers-fetch.ts @@ -42,6 +42,23 @@ export async function useServersFetch( retry = method === "GET" ? 3 : 0, } = options; + const circuitBreakerKey = `${module || "default"}_${path}`; + const failureCount = useState(`fetch_failures_${circuitBreakerKey}`, () => 0); + const lastFailureTime = useState(`last_failure_${circuitBreakerKey}`, () => 0); + + const now = Date.now(); + if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) { + const error = new ModrinthServersFetchError( + "[Modrinth Servers] Circuit breaker open - too many recent failures", + 503, + ); + throw new ModrinthServerError("Service temporarily unavailable", 503, error, module); + } + + if (now - lastFailureTime.value > 30000) { + failureCount.value = 0; + } + const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( /\/$/, "", @@ -99,6 +116,7 @@ export async function useServersFetch( timeout: 10000, }); + failureCount.value = 0; return response; } catch (error) { lastError = error as Error; @@ -108,6 +126,11 @@ export async function useServersFetch( const statusCode = error.response?.status; const statusText = error.response?.statusText || "Unknown error"; + if (statusCode && statusCode >= 500) { + failureCount.value++; + lastFailureTime.value = now; + } + let v1Error: V1ErrorInfo | undefined; if (error.data?.error && error.data?.description) { v1Error = { @@ -135,9 +158,11 @@ export async function useServersFetch( ? errorMessages[statusCode] : `HTTP Error: ${statusCode || "unknown"} ${statusText}`; - const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true; + const isRetryable = statusCode ? [408, 429].includes(statusCode) : false; + const is5xxRetryable = + statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1; - if (!isRetryable || attempts >= maxAttempts) { + if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) { console.error("Fetch error:", error); const fetchError = new ModrinthServersFetchError( @@ -148,7 +173,8 @@ export async function useServersFetch( throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); } - const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000); + const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000; + const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000); console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`); await new Promise((resolve) => setTimeout(resolve, delay)); continue; diff --git a/apps/frontend/src/pages/servers/manage/[id].vue b/apps/frontend/src/pages/servers/manage/[id].vue index 35fd28343..1c5d01387 100644 --- a/apps/frontend/src/pages/servers/manage/[id].vue +++ b/apps/frontend/src/pages/servers/manage/[id].vue @@ -18,48 +18,25 @@ v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" > -
-
-
-
- -
-

Server upgrading

-
-

- Your server's hardware is currently being upgraded and will be back online shortly! -

-
-
+
-
-
-
-
- -
-

Server suspended

-
-

- {{ - serverData.suspension_reason === "cancelled" - ? "Your subscription has been cancelled." - : serverData.suspension_reason - ? `Your server has been suspended: ${serverData.suspension_reason}` - : "Your server has been suspended." - }} -
- Contact Modrinth Support if you believe this is an error. -

-
- - - -
+
-
-
-
-
- -
-

Server not found

-
-

- You don't have permission to view this server or it no longer exists. If you believe this - is an error, please contact Modrinth Support. -

-
- - - - - -
+
-
-
-
-
- -
-

Server Node Unavailable

+ + +
-
-
-
-
-
- -
-

Connection lost

-
- {{ - formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...` - }} -
+ + + +
-->
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 9b41b4f5c..3057f43de 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -16,6 +16,7 @@ export { default as DoubleIcon } from './base/DoubleIcon.vue' export { default as DropArea } from './base/DropArea.vue' export { default as DropdownSelect } from './base/DropdownSelect.vue' export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue' +export { default as ErrorInformationCard } from './base/ErrorInformationCard.vue' export { default as FileInput } from './base/FileInput.vue' export { default as FilterBar } from './base/FilterBar.vue' export type { FilterBarOption } from './base/FilterBar.vue' diff --git a/packages/utils/servers/errors/modrinth-server-error.ts b/packages/utils/servers/errors/modrinth-server-error.ts index ece8825c8..c0a548a77 100644 --- a/packages/utils/servers/errors/modrinth-server-error.ts +++ b/packages/utils/servers/errors/modrinth-server-error.ts @@ -54,6 +54,6 @@ export class ModrinthServerError extends Error { } super(errorMessage) - this.name = 'PyroServersFetchError' + this.name = 'ModrinthServersFetchError' } } diff --git a/packages/utils/servers/errors/modrinth-servers-fetch-error.ts b/packages/utils/servers/errors/modrinth-servers-fetch-error.ts index ce01e737f..a2df7baa3 100644 --- a/packages/utils/servers/errors/modrinth-servers-fetch-error.ts +++ b/packages/utils/servers/errors/modrinth-servers-fetch-error.ts @@ -5,6 +5,6 @@ export class ModrinthServersFetchError extends Error { public originalError?: Error, ) { super(message) - this.name = 'PyroFetchError' + this.name = 'ModrinthFetchError' } }