Skip to content

Commit 337b4e8

Browse files
authored
Display API error messages during image upload (#3227)
Closes #1859. Will merge this into main and then merge main into #3222. <img width="773" height="442" alt="image" src="https://github.com/user-attachments/assets/2443b3e6-a994-4b2a-8491-3cabc134e1bd" />
1 parent aa60820 commit 337b4e8

6 files changed

Lines changed: 94 additions & 27 deletions

File tree

app/api/__tests__/errors.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ describe('processServerError', () => {
4242
},
4343
}
4444
expect(processServerError('fakeThingView', parseError)).toEqual({
45-
code: undefined,
4645
message: 'Hi, you have an error',
4746
statusCode: 400,
4847
errorCode: undefined,
@@ -60,6 +59,7 @@ describe('processServerError', () => {
6059
expect(processServerError('fakeThingView', clientError)).toEqual({
6160
message: 'Error reading API response',
6261
statusCode: 200,
62+
requestId: undefined,
6363
})
6464
})
6565

app/components/form/SideModalForm.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
import { useEffect, useId, useState, type ReactNode } from 'react'
1010
import type { FieldValues, UseFormReturn } from 'react-hook-form'
1111

12-
import type { ApiError } from '@oxide/api'
13-
1412
import { useShouldAnimateModal } from '~/hooks/use-should-animate-modal'
1513
import { Button } from '~/ui/lib/Button'
1614
import { Modal } from '~/ui/lib/Modal'
@@ -39,8 +37,10 @@ type SideModalFormProps<TFieldValues extends FieldValues> = {
3937
// require loading and error so we can't forget to hook them up. there are a
4038
// few forms that don't need them, so we'll use dummy values
4139

42-
/** Error from the API call */
43-
submitError: ApiError | null
40+
// would be ApiError except image-upload synthesizes a local "name already
41+
// exists" error that isn't from the API. The modal only reads message and
42+
// errorCode anyway.
43+
submitError: { message: string; errorCode?: string } | null
4444
loading: boolean
4545

4646
/** Only needed if you need to override the default title (Create/Edit ${resourceName}) */

app/forms/image-upload.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
q,
1919
queryClient,
2020
useApiMutation,
21-
type ApiError,
2221
type BlockSize,
2322
type Disk,
2423
type Snapshot,
@@ -54,6 +53,24 @@ import { formatBytes, GiB, KiB } from '~/util/units'
5453
// Padded because otherwise the numbers jump around a bit, e.g., when it goes
5554
// from 10.55 to 14.7 to 19.23
5655
const fsize = (bytes: number) => formatBytes(bytes, { pad: true }).label
56+
const genericUploadErrorMessage = 'Something went wrong. Please try again.'
57+
58+
function getUploadErrorMessage(e: unknown): string {
59+
if (!e || typeof e !== 'object') return genericUploadErrorMessage
60+
const errorCode = 'errorCode' in e ? e.errorCode : undefined
61+
const message = 'message' in e ? e.message : undefined
62+
63+
// Mutation errors are ApiErrors with user-facing messages from
64+
// processServerError. Show the API message only when it's actually
65+
// user-facing: errorCode "Internal" maps to dropshot's generic 500
66+
// ("Internal Server Error") with no real detail, and a missing message
67+
// means we got nothing to display.
68+
return typeof errorCode === 'string' &&
69+
typeof message === 'string' &&
70+
errorCode !== 'Internal'
71+
? message
72+
: genericUploadErrorMessage
73+
}
5774

5875
type FormValues = {
5976
imageName: string
@@ -132,6 +149,7 @@ function getTmpDiskName(imageName: string) {
132149
// do the right thing in
133150
const specialNames = new Set([
134151
'disk-create-500',
152+
'disk-create-quota',
135153
'import-start-500',
136154
'import-stop-500',
137155
'disk-finalize-500',
@@ -195,7 +213,7 @@ export default function ImageCreate() {
195213
// are submitting, we rely on the RQ mutations themselves, plus a synthetic
196214
// mutation state representing the many calls of the bulk upload step.
197215

198-
const [formError, setFormError] = useState<ApiError | null>(null)
216+
const [formError, setFormError] = useState<{ message: string } | null>(null)
199217
const [modalOpen, setModalOpen] = useState(false)
200218
const [modalError, setModalError] = useState<string | null>(null)
201219

@@ -536,10 +554,7 @@ export default function ImageCreate() {
536554
if (image) {
537555
// TODO: set this error on the field instead of the whole form
538556
// TODO: make setError available here somehow :(
539-
setFormError({
540-
errorCode: 'ObjectAlreadyExists',
541-
message: 'Image name already exists',
542-
})
557+
setFormError({ message: 'Image name already exists' })
543558
return
544559
}
545560

@@ -552,7 +567,7 @@ export default function ImageCreate() {
552567
} catch (e) {
553568
if (e !== ABORT_ERROR) {
554569
console.error(e)
555-
setModalError('Something went wrong. Please try again.')
570+
setModalError(getUploadErrorMessage(e))
556571
// abort anything in flight in case
557572
cancelEverything()
558573
}

mock-api/msw/handlers.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
getBlockSize,
5656
handleMetrics,
5757
handleOxqlMetrics,
58+
internalError,
5859
invalidRequest,
5960
ipRangeLen,
6061
NotImplemented,
@@ -157,7 +158,22 @@ export const handlers = makeHandlers({
157158

158159
errIfExists(db.disks, { name: body.name, project_id: project.id })
159160

160-
if (body.name === 'disk-create-500') throw 500
161+
if (body.name === 'disk-create-500') throw internalError('disk create failed')
162+
163+
// Mirrors the InsufficientCapacity error from omicron's virtual
164+
// provisioning collection update when a project's storage quota is
165+
// exceeded. See NOT_ENOUGH_STORAGE_SENTINEL in
166+
// https://github.com/oxidecomputer/omicron/blob/aa608203/nexus/db-queries/src/db/queries/virtual_provisioning_collection_update.rs#L61-L67
167+
if (body.name === 'disk-create-quota') {
168+
throw json(
169+
{
170+
error_code: 'InsufficientCapacity',
171+
message:
172+
'Storage Limit Exceeded: Not enough storage to complete request. Either remove unneeded disks and snapshots to free up resources or contact the rack operator to request a capacity increase.',
173+
},
174+
{ status: 507 }
175+
)
176+
}
161177

162178
const { name, description, size, disk_backend } = body
163179
const diskSource = disk_backend.type === 'distributed' ? disk_backend.disk_source : null
@@ -220,7 +236,7 @@ export const handlers = makeHandlers({
220236
async diskBulkWriteImportStart({ path, query }) {
221237
const disk = lookup.disk({ ...path, ...query })
222238

223-
if (disk.name === 'import-start-500') throw 500
239+
if (disk.name === 'import-start-500') throw internalError('import start failed')
224240

225241
if (disk.state.state !== 'import_ready') {
226242
throw 'Can only enter state importing_from_bulk_write from import_ready'
@@ -235,7 +251,7 @@ export const handlers = makeHandlers({
235251
async diskBulkWriteImportStop({ path, query }) {
236252
const disk = lookup.disk({ ...path, ...query })
237253

238-
if (disk.name === 'import-stop-500') throw 500
254+
if (disk.name === 'import-stop-500') throw internalError('import stop failed')
239255

240256
if (disk.state.state !== 'importing_from_bulk_writes') {
241257
throw 'Can only stop import for disk in state importing_from_bulk_write'
@@ -258,7 +274,7 @@ export const handlers = makeHandlers({
258274
diskFinalizeImport: ({ path, query, body }) => {
259275
const disk = lookup.disk({ ...path, ...query })
260276

261-
if (disk.name === 'disk-finalize-500') throw 500
277+
if (disk.name === 'disk-finalize-500') throw internalError('disk finalize failed')
262278

263279
if (disk.state.state !== 'import_ready') {
264280
throw `Cannot finalize disk in state ${disk.state.state}. Must be import_ready.`

mock-api/msw/util.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,16 @@ export const NotImplemented = () => {
113113
export const invalidRequest = (message: string) =>
114114
json({ error_code: 'InvalidRequest', message }, { status: 400 })
115115

116-
export const internalError = (message: string) =>
117-
json({ error_code: 'InternalError', message }, { status: 500 })
116+
// 500s in Omicron come from Error::InternalError, which turns into dropshot's
117+
// `for_internal_error`, which sets error_code "Internal" and a external message
118+
// of "Internal Server Error". It also has an `internal_message` that gets
119+
// logged but isn't sent to clients. We imitate that here.
120+
// https://github.com/oxidecomputer/omicron/blob/985304a/common/src/api/external/error.rs#L474-L476
121+
// https://github.com/oxidecomputer/dropshot/blob/9b431d1/dropshot/src/error.rs#L229-L241
122+
export const internalError = (internalMessage?: string) => {
123+
if (internalMessage) console.error(internalMessage)
124+
return json({ error_code: 'Internal', message: 'Internal Server Error' }, { status: 500 })
125+
}
118126

119127
export const errIfExists = <T extends Record<string, unknown>>(
120128
collection: T[],

test/e2e/image-upload.e2e.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,39 @@ test.describe('Image upload', () => {
262262
await expectUploadProcess(page)
263263
})
264264

265+
// generic 500s (errorCode "Internal") have no useful API message, so we fall
266+
// back to a generic one; specific error codes like InsufficientCapacity carry
267+
// a real user-facing message that we surface verbatim.
268+
const genericMessage = 'Something went wrong. Please try again.'
265269
const failureCases = [
266-
{ imageName: 'disk-create-500', stepText: 'Create temporary disk' },
267-
{ imageName: 'import-start-500', stepText: 'Put disk in import mode' },
268-
{ imageName: 'import-stop-500', stepText: 'Get disk out of import mode' },
269-
{ imageName: 'disk-finalize-500', stepText: 'Finalize disk and create snapshot' },
270+
{
271+
imageName: 'disk-create-500',
272+
stepText: 'Create temporary disk',
273+
message: genericMessage,
274+
},
275+
{
276+
imageName: 'disk-create-quota',
277+
stepText: 'Create temporary disk',
278+
message: 'Storage Limit Exceeded',
279+
},
280+
{
281+
imageName: 'import-start-500',
282+
stepText: 'Put disk in import mode',
283+
message: genericMessage,
284+
},
285+
{
286+
imageName: 'import-stop-500',
287+
stepText: 'Get disk out of import mode',
288+
message: genericMessage,
289+
},
290+
{
291+
imageName: 'disk-finalize-500',
292+
stepText: 'Finalize disk and create snapshot',
293+
message: genericMessage,
294+
},
270295
]
271296

272-
for (const { imageName, stepText } of failureCases) {
297+
for (const { imageName, stepText, message } of failureCases) {
273298
test(`failure ${imageName}`, async ({ page, browserName }) => {
274299
// eslint-disable-next-line playwright/no-skipped-test
275300
test.skip(browserName === 'webkit', 'safari. stop this')
@@ -280,10 +305,13 @@ test.describe('Image upload', () => {
280305

281306
const step = page.getByTestId(`upload-step: ${stepText}`)
282307
await expect(step).toHaveAttribute('data-status', 'error', { timeout: 15000 })
283-
await expectVisible(page, [
284-
'text="Something went wrong. Please try again."',
285-
'role=button[name="Back"]',
286-
])
308+
309+
await expect(page.getByText(message)).toBeVisible()
310+
// confirm we don't show both the generic and a specific API message
311+
if (message !== genericMessage) {
312+
await expect(page.getByText(genericMessage)).toBeHidden()
313+
}
314+
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
287315
})
288316
}
289317
})

0 commit comments

Comments
 (0)