Skip to content
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

Adds server error messages to form actions bar #788

Merged
merged 14 commits into from
Apr 21, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
useApiQueryClient,
firewallRuleGetToPut,
} from '@oxide/api'
import { getServerError } from 'app/util/errors'

type FormProps = {
error: ErrorResponse | null
Expand Down Expand Up @@ -352,7 +351,7 @@ const CommonForm = ({ id, error }: FormProps) => {
</fieldset>
</SideModal.Section>
<SideModal.Section>
<div className="text-destructive">{getServerError(error)}</div>
<div className="text-destructive">{error?.error.message}</div>
Comment on lines -355 to +354
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This'll go away once we convert this form

</SideModal.Section>
</Form>
)
Expand Down
3 changes: 1 addition & 2 deletions app/pages/project/networking/VpcPage/modals/vpc-routers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Formik, Form } from 'formik'
import { Button, FieldLabel, SideModal_old as SideModal, TextField } from '@oxide/ui'
import type { VpcRouter, ErrorResponse } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { getServerError } from 'app/util/errors'

// this will get a lot more interesting once the API is updated to allow us to
// put rules in the router, which is the whole point of a router
Expand Down Expand Up @@ -41,7 +40,7 @@ const CommonForm = ({ error, id }: FormProps) => (
</div>
</SideModal.Section>
<SideModal.Section>
<div className="text-destructive">{getServerError(error)}</div>
<div className="text-destructive">{error?.error.message}</div>
</SideModal.Section>
</Form>
)
Expand Down
65 changes: 0 additions & 65 deletions app/util/errors.spec.ts

This file was deleted.

38 changes: 0 additions & 38 deletions app/util/errors.ts

This file was deleted.

8 changes: 4 additions & 4 deletions libs/api/__tests__/hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('useApiQuery', () => {
const { result } = renderGetOrg503()

await waitFor(() =>
expect(result.current.error?.error).toEqual({
expect(result.current.error?.error).toMatchObject({
errorCode: 'ServiceUnavailable',
})
)
Expand Down Expand Up @@ -82,7 +82,7 @@ describe('useApiQuery', () => {
await waitFor(() => {
const error = result.error as ErrorResponse | undefined
expect(error?.status).toEqual(404)
expect(error?.error).toEqual({ errorCode: 'ObjectNotFound' })
expect(error?.error).toMatchObject({ errorCode: 'ObjectNotFound' })
})
})

Expand All @@ -98,7 +98,7 @@ describe('useApiQuery', () => {
)

await waitFor(() =>
expect(result.current.error?.error).toEqual({
expect(result.current.error?.error).toMatchObject({
errorCode: 'ObjectNotFound',
})
)
Expand Down Expand Up @@ -155,7 +155,7 @@ describe('useApiMutation', () => {
act(() => result.current.mutate(projectPost404Params))

await waitFor(() =>
expect(result.current.error?.error).toEqual({
expect(result.current.error?.error).toMatchObject({
errorCode: 'ObjectNotFound',
})
)
Expand Down
134 changes: 134 additions & 0 deletions libs/api/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { navToLogin } from './nav-to-login'
import type { ApiMethods, ErrorResponse } from '.'
import { capitalize } from '@oxide/util'

// Generic messages that work anywhere. There will probably be few or none of
// these, but it's convenient for now.
const globalCodeMap: Record<string, string> = {
Forbidden: 'Action not authorized',
}

const methodCodeMap: { [key in keyof Partial<ApiMethods>]: Record<string, string> } = {
organizationsPost: {
ObjectAlreadyExists: 'An organization with that name already exists',
},
projectInstancesPost: {
ObjectAlreadyExists: 'An instance with that name already exists in this project',
},
projectDisksPost: {
ObjectAlreadyExists: 'A disk with that name already exists in this project',
},
projectVpcsPost: {
ObjectAlreadyExists: 'A VPC with that name already exists in this project',
},
vpcSubnetsPost: {
ObjectAlreadyExists: 'A Subnet with that name already exists in this project',
},
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can imagine how this could get incredibly verbose. Definitely need to come up with a better way to break it down.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could easily put this together dynamically if each endpoint was tagged with the name of the resource and the name of the parent, but that really feels like API logic. At least for this specific error, I don't see why the API couldn't produce this sentence for us. To me the fact that this mapping feels like it belongs in the API layer of the console suggests it could really go in the API itself.

That doesn't solve the problem in general, because I'm sure there will be cases where we want to say something more specific than would be appropriate for an API message. But for those cases maybe we don't need to think of them as API-layer errors but rather as part of the logic of the calling form, like I had before at the page level:

const ERROR_CODES = {
ObjectAlreadyExists:
'A project with that name already exists in this organization',
}

Maybe the solution is to get as many of these messages as we can from the API, and for the remaining ones we encode them at the form level?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this should come more from the API. Any transformations or mapping that we have to do on the client are a bit of a smell. One clear way we could resolve this is to change the ObjectAlreadyExists error in Nexus to require the type of the object that already exists. I mean, if an ObjectAlreadyExists error is spawned in a saga it might not be clear what object doesn't exist as we've built it now.

I'm not necessarily convinced that spreading the errors out makes any real difference. I'm afraid that would lead to inconsistent implementation of error messaging because the only context you have is what's already in the form you're looking at (or another form you reference). Having them centralized makes it easy to see all the custom errors at a glance. From an architectural perspective I also don't feel like the forms should have any specific knowledge of an error. Ideally it just displays whatever is given.

Copy link
Collaborator

@david-crespo david-crespo Apr 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think spreading them out into the callers would make sense only if there are just a few very context-specific cases we can't get the API to handle, in which case we would think of them as UI copy.

That's a good point about sagas. With composite endpoints like instance create, we really do need more info from the API no matter what, and I'm inclined toward having it just write a nice message instead of trying to come up with an abstract scheme of all the bits of info we might need to come up with such a message.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the Error enum in omicron, it looks like some of these messages are already build out. I think they could be tweaked, but that much is promising.


export const handleErrors =
<M>(method: M) =>
zephraph marked this conversation as resolved.
Show resolved Hide resolved
(resp: ErrorResponse) => {
// TODO is this a valid failure condition?
if (!resp) throw 'unknown server error'

// if logged out, hit /login to trigger login redirect
if (resp.status === 401) {
// TODO-usability: for background requests, a redirect to login without
// warning could come as a surprise to the user, especially because
// sometimes background requests are not directly triggered by a user
// action, e.g., polling or refetching when window regains focus
navToLogin({ includeCurrent: true })
}
// we need to rethrow because that's how react-query knows it's an error
throw formatServerError(resp, methodCodeMap[method as unknown as keyof ApiMethods])
}

function formatServerError(
resp: ErrorResponse,
codeMap: Record<string, string> = {}
): ErrorResponse {
const code = resp.error.errorCode
const codeMsg = code && (codeMap[code] || globalCodeMap[code])
const serverMsg = resp.error.message

resp.error.message =
codeMsg || getParseError(serverMsg) || serverMsg || 'Unknown server error'

return resp
}

function getParseError(message: string | undefined): string | undefined {
if (!message) return undefined
const inner = /^unable to parse body: (.+) at line \d+ column \d+$/.exec(message)?.[1]
return inner && capitalize(inner)
}

// -- TESTS ----------------

if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
const parseError = {
error: {
requestId: '1',
errorCode: null,
message: 'unable to parse body: hello there, you have an error at line 129 column 4',
},
} as ErrorResponse

const alreadyExists = {
error: {
requestId: '2',
errorCode: 'ObjectAlreadyExists',
message: 'whatever',
},
} as ErrorResponse

const unauthorized = {
error: {
requestId: '3',
errorCode: 'Forbidden',
message: "I'm afraid you can't do that, Dave",
},
} as ErrorResponse

describe('getParseError', () => {
it('extracts nice part of error message', () => {
expect(getParseError(parseError.error.message)).toEqual(
'Hello there, you have an error'
)
})

it('returns undefined if error does not match pattern', () => {
expect(getParseError('some nonsense')).toBeUndefined()
})
})

describe('getServerError', () => {
it('extracts message from parse errors', () => {
expect(formatServerError(parseError, {}).error.message).toEqual(
'Hello there, you have an error'
)
})

it('uses message from code map if error code matches', () => {
expect(
formatServerError(alreadyExists, {
ObjectAlreadyExists: 'that already exists',
}).error.message
).toEqual('that already exists')
})

it('falls back to server error message if code not found', () => {
expect(
formatServerError(alreadyExists, { NotACode: 'stop that' }).error.message
).toEqual('whatever')
})

it('uses global map of generic codes for, e.g., 403s', () => {
expect(formatServerError(unauthorized, {}).error.message).toEqual(
'Action not authorized'
)
})
})
}
19 changes: 3 additions & 16 deletions libs/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
QueryKey,
} from 'react-query'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { navToLogin } from './nav-to-login'
import { handleErrors } from './errors'

import type { ErrorResponse, ApiResponse } from './__generated__/Api'

Expand All @@ -28,19 +28,6 @@ export type ResultItem<F> = F extends (
type ApiClient = Record<string, (...args: any) => Promise<ApiResponse<any>>>
/* eslint-enable @typescript-eslint/no-explicit-any */

function navToLoginIf401(resp: ErrorResponse) {
// if logged out, hit /login to trigger login redirect
if (resp.status === 401) {
// TODO-usability: for background requests, a redirect to login without
// warning could come as a surprise to the user, especially because
// sometimes background requests are not directly triggered by a user
// action, e.g., polling or refetching when window regains focus
navToLogin({ includeCurrent: true })
}
// we need to rethrow because that's how react-query knows it's an error
throw resp
}

export const getUseApiQuery =
<A extends ApiClient>(api: A) =>
<M extends keyof A>(
Expand All @@ -65,7 +52,7 @@ export const getUseApiQuery =
() =>
api[method](params)
.then((resp) => resp.data)
.catch(navToLoginIf401),
.catch(handleErrors(method)),
{
// In the case of 404s, let the error bubble up to the error boundary so
// we can say Not Found. If you need to allow a 404 and want it to show
Expand All @@ -87,7 +74,7 @@ export const getUseApiMutation =
({ body, ...params }) =>
api[method](params, body)
.then((resp) => resp.data)
.catch(navToLoginIf401),
.catch(handleErrors(method)),
options
)

Expand Down
8 changes: 5 additions & 3 deletions libs/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const api = new Api({
baseUrl: process.env.API_URL,
})

type A = typeof api.methods
export type ApiMethods = typeof api.methods

/**
* API methods that return a list of items.
Expand All @@ -15,12 +15,14 @@ export type ApiListMethods = {
// Only never extends never. If ResultItem extends never, that means we
// couldn't pull out an item, which means it's not a list endpoint. If we can
// infer an item, that means it's a list endpoint, so include its M.
[M in keyof A as ResultItem<A[M]> extends never ? never : M]: A[M]
[M in keyof ApiMethods as ResultItem<ApiMethods[M]> extends never
? never
: M]: ApiMethods[M]
}

export const useApiQuery = getUseApiQuery(api.methods)
export const useApiMutation = getUseApiMutation(api.methods)
export const useApiQueryClient = getUseApiQueryClient<A>()
export const useApiQueryClient = getUseApiQueryClient<ApiMethods>()

export * from './util'
export * from './__generated__/Api'
Expand Down
Loading