mirror of
https://github.com/decompme/decomp.me.git
synced 2026-02-15 02:48:55 -06:00
Have the frontend handle the backend going down more gracefully (#827)
* Identify and throw RequestFailure errors, handle network errors with a special error page * Blanket catch and handle all SWR network errors on the scratch page * Revert scratcheditor / scratchpage change * Have the scratch editor be able to gracefully handle being offline * maintain "maintenance" --------- Co-authored-by: ConorBobbleHat <c.github@firstpartners.net>
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
|
||||
"use client"
|
||||
|
||||
import Error from "../error"
|
||||
import ErrorPage from "../error"
|
||||
|
||||
export default Error
|
||||
export default ErrorPage
|
||||
|
||||
@@ -7,22 +7,36 @@ import { SyncIcon } from "@primer/octicons-react"
|
||||
import Button from "@/components/Button"
|
||||
import ErrorBoundary from "@/components/ErrorBoundary"
|
||||
import SetPageTitle from "@/components/SetPageTitle"
|
||||
import { RequestFailedError } from "@/lib/api"
|
||||
|
||||
export const metadata = {
|
||||
title: "Error",
|
||||
type ErrorPageProps = {error: Error, reset: () => void };
|
||||
|
||||
function NetworkErrorPage({ error, reset }: ErrorPageProps) {
|
||||
return <>
|
||||
<SetPageTitle title="Error" />
|
||||
<div className="grow" />
|
||||
<main className="max-w-prose p-4 md:mx-auto">
|
||||
<h1 className="py-4 text-3xl font-semibold">We're having some trouble reaching the backend</h1>
|
||||
|
||||
<div className="rounded bg-gray-2 p-4 text-gray-11">
|
||||
<code className="font-mono text-sm">{error.toString()}</code>
|
||||
</div>
|
||||
|
||||
<p className="py-4">
|
||||
If your internet connection is okay, we're probably down for maintenance, and will be back shortly. If this issue persists - <a href="https://discord.gg/sutqNShRRs" className="text-blue-11 hover:underline active:translate-y-px">let us know</a>.
|
||||
</p>
|
||||
|
||||
<ErrorBoundary>
|
||||
<Button onClick={reset}>
|
||||
<SyncIcon /> Try again
|
||||
</Button>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<div className="grow" />
|
||||
</>
|
||||
}
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
function UnexpectedErrorPage({ error, reset }: ErrorPageProps) {
|
||||
return <>
|
||||
<SetPageTitle title="Error" />
|
||||
<div className="grow" />
|
||||
@@ -46,3 +60,15 @@ export default function Error({
|
||||
<div className="grow" />
|
||||
</>
|
||||
}
|
||||
|
||||
export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
||||
useEffect(() => {
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return error instanceof RequestFailedError ? <NetworkErrorPage error={error} reset={reset} /> : <UnexpectedErrorPage error={error} reset={reset} />
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Error",
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import useSWR from "swr"
|
||||
import useSWR, { Middleware, SWRConfig } from "swr"
|
||||
|
||||
import Scratch from "@/components/Scratch"
|
||||
import useWarnBeforeScratchUnload from "@/components/Scratch/hooks/useWarnBeforeScratchUnload"
|
||||
@@ -18,13 +18,7 @@ function ScratchPageTitle({ scratch }: { scratch: api.Scratch }) {
|
||||
return <SetPageTitle title={title} />
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
initialScratch: api.Scratch
|
||||
parentScratch?: api.Scratch
|
||||
initialCompilation?: api.Compilation
|
||||
}
|
||||
|
||||
export default function ScratchEditor({ initialScratch, parentScratch, initialCompilation }: Props) {
|
||||
function ScratchEditorInner({ initialScratch, parentScratch, initialCompilation, offline }: Props) {
|
||||
const [scratch, setScratch] = useState(initialScratch)
|
||||
|
||||
useWarnBeforeScratchUnload(scratch)
|
||||
@@ -75,7 +69,42 @@ export default function ScratchEditor({ initialScratch, parentScratch, initialCo
|
||||
return { ...scratch, ...partial }
|
||||
})
|
||||
}}
|
||||
offline={offline}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
initialScratch: api.Scratch
|
||||
parentScratch?: api.Scratch
|
||||
initialCompilation?: api.Compilation
|
||||
offline?: boolean
|
||||
}
|
||||
|
||||
export default function ScratchEditor(props: Props) {
|
||||
const [offline, setOffline] = useState(false)
|
||||
|
||||
const offlineMiddleware: Middleware = _useSWRNext => {
|
||||
return (key, fetcher, config) => {
|
||||
let swr = _useSWRNext(key, fetcher, config)
|
||||
|
||||
if (swr.error instanceof api.RequestFailedError) {
|
||||
setOffline(true)
|
||||
swr = Object.assign({}, swr, { error: null })
|
||||
}
|
||||
|
||||
return swr
|
||||
}
|
||||
}
|
||||
|
||||
const onSuccess = () => {
|
||||
setOffline(false)
|
||||
}
|
||||
|
||||
return <>
|
||||
<SWRConfig value={{ use: [offlineMiddleware], onSuccess }}>
|
||||
<ScratchEditorInner {...props} offline={offline} />
|
||||
</SWRConfig>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export type Props = {
|
||||
onChange: (scratch: Partial<api.Scratch>) => void
|
||||
parentScratch?: api.Scratch
|
||||
initialCompilation?: Readonly<api.Compilation>
|
||||
offline: boolean
|
||||
}
|
||||
|
||||
export default function Scratch({
|
||||
@@ -123,6 +124,7 @@ export default function Scratch({
|
||||
onChange,
|
||||
parentScratch,
|
||||
initialCompilation,
|
||||
offline,
|
||||
}: Props) {
|
||||
const container = useSize<HTMLDivElement>()
|
||||
const [layout, setLayout] = useState<Layout>(undefined)
|
||||
@@ -286,6 +288,15 @@ export default function Scratch({
|
||||
}
|
||||
}
|
||||
|
||||
const offlineOverlay = (
|
||||
offline ? <>
|
||||
<div className="fixed top-10 self-center rounded bg-red-8 px-3 py-2">
|
||||
<p className="text-sm">The scratch editor is in offline mode. We're attempting to reconnect to the backend - as long as this tab is open, your work is safe.</p>
|
||||
</div>
|
||||
</>
|
||||
: <></>
|
||||
)
|
||||
|
||||
return <div ref={container.ref} className={styles.container}>
|
||||
<ErrorBoundary>
|
||||
<ScratchMatchBanner scratch={scratch} />
|
||||
@@ -306,5 +317,6 @@ export default function Scratch({
|
||||
renderTab={renderTab}
|
||||
/>}
|
||||
</ErrorBoundary>
|
||||
{offlineOverlay}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ export class ResponseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestFailedError extends Error {
|
||||
constructor(message: string, url: string) {
|
||||
super(`${message} (occured when fetching ${url})`)
|
||||
this.name = "RequestFailedError"
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeUrl(url: string) {
|
||||
if (url.startsWith("/")) {
|
||||
url = API_BASE + url
|
||||
@@ -40,22 +47,36 @@ export function normalizeUrl(url: string) {
|
||||
return url
|
||||
}
|
||||
|
||||
export async function get(url: string) {
|
||||
export async function errorHandledFetchJson(url: string, init?: RequestInit) {
|
||||
let response: Response
|
||||
|
||||
url = normalizeUrl(url)
|
||||
|
||||
console.info("GET", url)
|
||||
try {
|
||||
response = await fetch(url, init)
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
throw new RequestFailedError(error.message, url)
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...commonOpts,
|
||||
cache: "no-cache",
|
||||
next: { revalidate: 10 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response, await response.json())
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
if (response.status == 502) {
|
||||
// We've received a "Gateway Unavailable" message from nginx.
|
||||
// The backend's down.
|
||||
throw new RequestFailedError("Backend gateway unavailable", url)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response, await response.json())
|
||||
}
|
||||
|
||||
if (response.status == 204) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
@@ -69,10 +90,17 @@ export async function get(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function post(url: string, data: Json | FormData, method = "POST") {
|
||||
url = normalizeUrl(url)
|
||||
export async function get(url: string) {
|
||||
console.info("GET", normalizeUrl(url))
|
||||
return await errorHandledFetchJson(url, {
|
||||
...commonOpts,
|
||||
cache: "no-cache",
|
||||
next: { revalidate: 10 },
|
||||
})
|
||||
}
|
||||
|
||||
console.info(method, url, data)
|
||||
export async function post(url: string, data: Json | FormData, method = "POST") {
|
||||
console.info(method, normalizeUrl(url), data)
|
||||
|
||||
let body: string | FormData
|
||||
if (data instanceof FormData) {
|
||||
@@ -81,7 +109,7 @@ export async function post(url: string, data: Json | FormData, method = "POST")
|
||||
body = JSON.stringify(data)
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
return await errorHandledFetchJson(url, {
|
||||
...commonOpts,
|
||||
method,
|
||||
body,
|
||||
@@ -89,16 +117,6 @@ export async function post(url: string, data: Json | FormData, method = "POST")
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response, await response.json())
|
||||
}
|
||||
|
||||
if (response.status == 204) {
|
||||
return null
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export async function patch(url: string, data: Json | FormData) {
|
||||
|
||||
Reference in New Issue
Block a user