Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/__tests__/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ describe('processServerError', () => {
},
}
expect(processServerError('fakeThingView', parseError)).toEqual({
code: undefined,
message: 'Hi, you have an error',
statusCode: 400,
errorCode: undefined,
Expand All @@ -60,6 +59,7 @@ describe('processServerError', () => {
expect(processServerError('fakeThingView', clientError)).toEqual({
message: 'Error reading API response',
statusCode: 200,
requestId: undefined,
})
})

Expand Down
8 changes: 4 additions & 4 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
import { useEffect, useId, useState, type ReactNode } from 'react'
import type { FieldValues, UseFormReturn } from 'react-hook-form'

import type { ApiError } from '@oxide/api'

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

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

/** Only needed if you need to override the default title (Create/Edit ${resourceName}) */
Expand Down
29 changes: 22 additions & 7 deletions app/forms/image-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
q,
queryClient,
useApiMutation,
type ApiError,
type BlockSize,
type Disk,
type Snapshot,
Expand Down Expand Up @@ -54,6 +53,24 @@ import { formatBytes, GiB, KiB } from '~/util/units'
// Padded because otherwise the numbers jump around a bit, e.g., when it goes
// from 10.55 to 14.7 to 19.23
const fsize = (bytes: number) => formatBytes(bytes, { pad: true }).label
const genericUploadErrorMessage = 'Something went wrong. Please try again.'

function getUploadErrorMessage(e: unknown): string {
if (!e || typeof e !== 'object') return genericUploadErrorMessage
const errorCode = 'errorCode' in e ? e.errorCode : undefined
const message = 'message' in e ? e.message : undefined

// Mutation errors are ApiErrors with user-facing messages from
// processServerError. Show the API message only when it's actually
// user-facing: errorCode "Internal" maps to dropshot's generic 500
// ("Internal Server Error") with no real detail, and a missing message
// means we got nothing to display.
return typeof errorCode === 'string' &&
typeof message === 'string' &&
errorCode !== 'Internal'
? message
: genericUploadErrorMessage
}

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

const [formError, setFormError] = useState<ApiError | null>(null)
const [formError, setFormError] = useState<{ message: string } | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)

Expand Down Expand Up @@ -536,10 +554,7 @@ export default function ImageCreate() {
if (image) {
// TODO: set this error on the field instead of the whole form
// TODO: make setError available here somehow :(
setFormError({
errorCode: 'ObjectAlreadyExists',
message: 'Image name already exists',
})
setFormError({ message: 'Image name already exists' })
return
}

Expand All @@ -552,7 +567,7 @@ export default function ImageCreate() {
} catch (e) {
if (e !== ABORT_ERROR) {
console.error(e)
setModalError('Something went wrong. Please try again.')
setModalError(getUploadErrorMessage(e))
// abort anything in flight in case
cancelEverything()
}
Expand Down
24 changes: 20 additions & 4 deletions mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
getBlockSize,
handleMetrics,
handleOxqlMetrics,
internalError,
invalidRequest,
ipRangeLen,
NotImplemented,
Expand Down Expand Up @@ -157,7 +158,22 @@ export const handlers = makeHandlers({

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

if (body.name === 'disk-create-500') throw 500
if (body.name === 'disk-create-500') throw internalError('disk create failed')

// Mirrors the InsufficientCapacity error from omicron's virtual
// provisioning collection update when a project's storage quota is
// exceeded. See NOT_ENOUGH_STORAGE_SENTINEL in
// https://github.com/oxidecomputer/omicron/blob/aa608203/nexus/db-queries/src/db/queries/virtual_provisioning_collection_update.rs#L61-L67
if (body.name === 'disk-create-quota') {
throw json(
{
error_code: 'InsufficientCapacity',
message:
'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.',
},
{ status: 507 }
)
}

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

if (disk.name === 'import-start-500') throw 500
if (disk.name === 'import-start-500') throw internalError('import start failed')

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

if (disk.name === 'import-stop-500') throw 500
if (disk.name === 'import-stop-500') throw internalError('import stop failed')

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

if (disk.name === 'disk-finalize-500') throw 500
if (disk.name === 'disk-finalize-500') throw internalError('disk finalize failed')

if (disk.state.state !== 'import_ready') {
throw `Cannot finalize disk in state ${disk.state.state}. Must be import_ready.`
Expand Down
12 changes: 10 additions & 2 deletions mock-api/msw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,16 @@ export const NotImplemented = () => {
export const invalidRequest = (message: string) =>
json({ error_code: 'InvalidRequest', message }, { status: 400 })

export const internalError = (message: string) =>
json({ error_code: 'InternalError', message }, { status: 500 })
Copy link
Copy Markdown
Collaborator Author

@david-crespo david-crespo May 22, 2026

Choose a reason for hiding this comment

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

Turns out InternalError was wrong, and we need it to be correct because we're adding client logic in this PR that is conditional on error_code !== 'Internal'.

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

export const errIfExists = <T extends Record<string, unknown>>(
collection: T[],
Expand Down
46 changes: 37 additions & 9 deletions test/e2e/image-upload.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,14 +262,39 @@ test.describe('Image upload', () => {
await expectUploadProcess(page)
})

// generic 500s (errorCode "Internal") have no useful API message, so we fall
// back to a generic one; specific error codes like InsufficientCapacity carry
// a real user-facing message that we surface verbatim.
const genericMessage = 'Something went wrong. Please try again.'
const failureCases = [
{ imageName: 'disk-create-500', stepText: 'Create temporary disk' },
{ imageName: 'import-start-500', stepText: 'Put disk in import mode' },
{ imageName: 'import-stop-500', stepText: 'Get disk out of import mode' },
{ imageName: 'disk-finalize-500', stepText: 'Finalize disk and create snapshot' },
{
imageName: 'disk-create-500',
stepText: 'Create temporary disk',
message: genericMessage,
},
{
imageName: 'disk-create-quota',
stepText: 'Create temporary disk',
message: 'Storage Limit Exceeded',
},
{
imageName: 'import-start-500',
stepText: 'Put disk in import mode',
message: genericMessage,
},
{
imageName: 'import-stop-500',
stepText: 'Get disk out of import mode',
message: genericMessage,
},
{
imageName: 'disk-finalize-500',
stepText: 'Finalize disk and create snapshot',
message: genericMessage,
},
]

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

const step = page.getByTestId(`upload-step: ${stepText}`)
await expect(step).toHaveAttribute('data-status', 'error', { timeout: 15000 })
await expectVisible(page, [
'text="Something went wrong. Please try again."',
'role=button[name="Back"]',
])

await expect(page.getByText(message)).toBeVisible()
// confirm we don't show both the generic and a specific API message
if (message !== genericMessage) {
await expect(page.getByText(genericMessage)).toBeHidden()
}
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
})
}
})
Expand Down
Loading