Skip to content

Commit

Permalink
Have the frontend handle the backend going down more gracefully (#827)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
ConorBobbleHat and ConorBobbleHat authored Aug 23, 2023
1 parent 86d5ea4 commit ca792ab
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 47 deletions.
4 changes: 2 additions & 2 deletions frontend/src/app/(navfooter)/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

"use client"

import Error from "../error"
import ErrorPage from "../error"

export default Error
export default ErrorPage
52 changes: 39 additions & 13 deletions frontend/src/app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
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" />
</>
}

function UnexpectedErrorPage({ error, reset }: ErrorPageProps) {
return <>
<SetPageTitle title="Error" />
<div className="grow" />
Expand All @@ -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",
}
45 changes: 37 additions & 8 deletions frontend/src/app/scratch/[slug]/ScratchEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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>
</>
}
12 changes: 12 additions & 0 deletions frontend/src/components/Scratch/Scratch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,15 @@ export type Props = {
onChange: (scratch: Partial<api.Scratch>) => void
parentScratch?: api.Scratch
initialCompilation?: Readonly<api.Compilation>
offline: boolean
}

export default function Scratch({
scratch,
onChange,
parentScratch,
initialCompilation,
offline,
}: Props) {
const container = useSize<HTMLDivElement>()
const [layout, setLayout] = useState<Layout>(undefined)
Expand Down Expand Up @@ -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} />
Expand All @@ -306,5 +317,6 @@ export default function Scratch({
renderTab={renderTab}
/>}
</ErrorBoundary>
{offlineOverlay}
</div>
}
66 changes: 42 additions & 24 deletions frontend/src/lib/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,50 @@ 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
}
return url
}

export async function get(url: string) {
url = normalizeUrl(url)
export async function errorHandledFetchJson(url: string, init?: RequestInit) {
let response: Response

console.info("GET", url)
url = normalizeUrl(url)

const response = await fetch(url, {
...commonOpts,
cache: "no-cache",
next: { revalidate: 10 },
})
try {
response = await fetch(url, init)
} catch (error) {
if (error instanceof TypeError) {
throw new RequestFailedError(error.message, url)
}

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) {
Expand All @@ -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) {
Expand All @@ -81,24 +109,14 @@ 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,
headers: body instanceof FormData ? {} : {
"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) {
Expand Down

0 comments on commit ca792ab

Please sign in to comment.