- Notifications
You must be signed in to change notification settings - Fork 1.4k
handle api errors in error boundary #17086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| /** | ||
| * Copyright (c) 2023 Gitpod GmbH. All rights reserved. | ||
| * Licensed under the GNU Affero General Public License (AGPL). | ||
| * See License.AGPL.txt in the project root for license information. | ||
| */ | ||
| | ||
| import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie"; | ||
| import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; | ||
| import { QueryErrorResetBoundary } from "@tanstack/react-query"; | ||
| import { FC, lazy, Suspense } from "react"; | ||
| import { ErrorBoundary, FallbackProps } from "react-error-boundary"; | ||
| import { AppLoading } from "../../app/AppLoading"; | ||
| import { Login } from "../../Login"; | ||
| import { isGitpodIo } from "../../utils"; | ||
| import { CaughtError } from "./ReloadPageErrorBoundary"; | ||
| | ||
| const Setup = lazy(() => import(/* webpackPrefetch: true */ "../../Setup")); | ||
| | ||
| // Error boundary intended to catch and handle expected errors from api calls | ||
| export const QueryErrorBoundary: FC = ({ children }) => { | ||
| return ( | ||
| <QueryErrorResetBoundary> | ||
| {({ reset }) => ( | ||
| // Passing reset to onReset so any queries know to retry after boundary is reset | ||
| <ErrorBoundary FallbackComponent={ExpectedQueryErrorsFallback} onReset={reset}> | ||
| Contributor Author There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. passing Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, nice! Wondered before how this happens/if it's the default/hard-wired behavior 💡 🧡 | ||
| {children} | ||
| </ErrorBoundary> | ||
| )} | ||
| </QueryErrorResetBoundary> | ||
| ); | ||
| }; | ||
| | ||
| // This handles any expected errors, i.e. errors w/ a code that an api call produced | ||
| const ExpectedQueryErrorsFallback: FC<FallbackProps> = ({ error, resetErrorBoundary }) => { | ||
| // adjust typing, as we may have caught an api error here w/ a code property | ||
| const caughtError = error as CaughtError; | ||
| | ||
| // User needs to Login | ||
| if (caughtError.code === ErrorCodes.NOT_AUTHENTICATED) { | ||
| // Before we show a Login screen, check to see if we need to redirect to www site | ||
| // Redirects if it's the root, no user, and no gp cookie present (has logged in recently) | ||
| if (isGitpodIo() && window.location.pathname === "/" && window.location.hash === "") { | ||
| // If there's no gp cookie, bounce to www site | ||
| if (!GitpodCookie.isPresent(document.cookie)) { | ||
| window.location.href = `https://www.gitpod.io`; | ||
| return <div></div>; | ||
| } | ||
| } | ||
| | ||
| return <Login onLoggedIn={resetErrorBoundary} />; | ||
| Contributor Author There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's important to reset the error boundary once we've gotten into a state that shouldn't produce it anymore. This tells react (and react query) to reset state, and try the render again. | ||
| } | ||
| | ||
| // Setup needed before proceeding | ||
| if (caughtError.code === ErrorCodes.SETUP_REQUIRED) { | ||
| return ( | ||
| <Suspense fallback={<AppLoading />}> | ||
| <Setup onComplete={resetErrorBoundary} /> | ||
| </Suspense> | ||
| ); | ||
| } | ||
| | ||
| // Otherwise throw the error for default error boundary to catch and handle | ||
| throw error; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| | @@ -4,26 +4,31 @@ | |
| * See License.AGPL.txt in the project root for license information. | ||
| */ | ||
| | ||
| import { FC } from "react"; | ||
| import { FC, useCallback } from "react"; | ||
| import { ErrorBoundary, FallbackProps, ErrorBoundaryProps } from "react-error-boundary"; | ||
| import gitpodIcon from "../icons/gitpod.svg"; | ||
| import { getGitpodService } from "../service/service"; | ||
| import { Heading1, Subheading } from "./typography/headings"; | ||
| import gitpodIcon from "../../icons/gitpod.svg"; | ||
| import { getGitpodService } from "../../service/service"; | ||
| import { Heading1, Subheading } from "../typography/headings"; | ||
| | ||
| export const GitpodErrorBoundary: FC = ({ children }) => { | ||
| export type CaughtError = Error & { code?: number }; | ||
| | ||
| // Catches any unexpected errors w/ a UI to reload the page. Also reports errors to api | ||
| export const ReloadPageErrorBoundary: FC = ({ children }) => { | ||
| return ( | ||
| <ErrorBoundary FallbackComponent={DefaultErrorFallback} onReset={handleReset} onError={handleError}> | ||
| <ErrorBoundary FallbackComponent={ReloadPageErrorFallback} onError={handleError}> | ||
| {children} | ||
| </ErrorBoundary> | ||
| ); | ||
| }; | ||
| | ||
| type CaughtError = Error & { code?: number }; | ||
| | ||
| export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBoundary }) => { | ||
| export const ReloadPageErrorFallback: FC<Pick<FallbackProps, "error">> = ({ error }) => { | ||
| // adjust typing, as we may have caught an api error here w/ a code property | ||
| const caughtError = error as CaughtError; | ||
| | ||
| const handleReset = useCallback(() => { | ||
| window.location.reload(); | ||
| }, []); | ||
| | ||
| const emailSubject = encodeURIComponent("Gitpod Dashboard Error"); | ||
| let emailBodyStr = `\n\nError: ${caughtError.message}`; | ||
| if (caughtError.code) { | ||
| | @@ -43,9 +48,9 @@ export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBound | |
| . | ||
| </Subheading> | ||
| <div> | ||
| <button onClick={resetErrorBoundary}>Reload</button> | ||
| <button onClick={handleReset}>Reload</button> | ||
| Contributor Author There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I this case we don't actually want to ask React to try and re-render, we want to just have the user reload the page. We may want to have a route-level error boundary that does attempt a re-render on reset though as a future improvement. | ||
| </div> | ||
| <div> | ||
| <div className="flex flex-col items-center space-y-2"> | ||
| {caughtError.code && ( | ||
| <span> | ||
| <strong>Code:</strong> {caughtError.code} | ||
| | @@ -57,10 +62,6 @@ export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBound | |
| ); | ||
| }; | ||
| | ||
| export const handleReset: ErrorBoundaryProps["onReset"] = () => { | ||
| window.location.reload(); | ||
| }; | ||
| | ||
| export const handleError: ErrorBoundaryProps["onError"] = async (error, info) => { | ||
| const url = window.location.toString(); | ||
| try { | ||
| | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| | @@ -4,55 +4,38 @@ | |
| * See License.AGPL.txt in the project root for license information. | ||
| */ | ||
| | ||
| import { useState, useContext } from "react"; | ||
| import { User } from "@gitpod/gitpod-protocol"; | ||
| import { useContext } from "react"; | ||
| import { UserContext } from "../user-context"; | ||
| import { getGitpodService } from "../service/service"; | ||
| import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; | ||
| import { trackLocation } from "../Analytics"; | ||
| import { refreshSearchData } from "../components/RepositoryFinder"; | ||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { noPersistence } from "../data/setup"; | ||
| | ||
| export const useUserLoader = () => { | ||
| const { user, setUser } = useContext(UserContext); | ||
| const [isSetupRequired, setSetupRequired] = useState(false); | ||
| | ||
| // For now, we're using the user context to store the user, but letting react-query handle the loading | ||
| // In the future, we should remove the user context and use react-query to access the user | ||
| const { isLoading } = useQuery({ | ||
| queryKey: noPersistence(["current-user"]), | ||
| queryFn: async () => { | ||
| let user: User | undefined; | ||
| try { | ||
| user = await getGitpodService().server.getLoggedInUser(); | ||
| setUser(user); | ||
| refreshSearchData(); | ||
| } catch (error) { | ||
| if (error && "code" in error) { | ||
| if (error.code === ErrorCodes.SETUP_REQUIRED) { | ||
| setSetupRequired(true); | ||
| return; | ||
| } | ||
| | ||
| // If it was a server error, throw it so we can catch it with an ErrorBoundary | ||
| if (error.code >= 500) { | ||
| throw error; | ||
| } | ||
| | ||
| // Other errors will treat user as needing to log in | ||
| } | ||
| } finally { | ||
| trackLocation(!!user); | ||
| } | ||
| const user = await getGitpodService().server.getLoggedInUser(); | ||
| Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wow. This looking really clean now! 🎉 Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, I think this in combination with the QueryErrorBoundary, could serve as one example on how to use react-query. | ||
| | ||
| return user || null; | ||
| }, | ||
| // We'll let an ErrorBoundary catch the error | ||
| useErrorBoundary: true, | ||
| cacheTime: 1000 * 60 * 60 * 1, // 1 hour | ||
| staleTime: 1000 * 60 * 60 * 1, // 1 hour | ||
| onSuccess: (loadedUser) => { | ||
| setUser(loadedUser); | ||
| refreshSearchData(); | ||
| }, | ||
| onSettled: (loadedUser) => { | ||
| trackLocation(!!loadedUser); | ||
| }, | ||
| }); | ||
| | ||
| return { user, loading: isLoading, isSetupRequired }; | ||
| return { user, loading: isLoading }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is superfluous now, right? Or am I missing sth? (asking to understand)