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:
ConorB
2023-08-23 19:47:42 +01:00
committed by GitHub
parent 86d5ea4aba
commit ca792ab04f
5 changed files with 132 additions and 47 deletions

View File

@@ -2,6 +2,6 @@
"use client"
import Error from "../error"
import ErrorPage from "../error"
export default Error
export default ErrorPage

View File

@@ -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",
}

View File

@@ -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>
</>
}

View File

@@ -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>
}

View File

@@ -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) {