feat(cli): strict ErrorBody parse composed onto HttpClientError

Replaces the tolerant WireFields/WireEnvelope parse in error-mapper with a strict
zErrorBody.safeParse; non-conforming bodies yield no serverError and a generic
message. The parsed body attaches whole as serverError?: ErrorBody on HttpClientError,
with classification driven purely by HTTP status.
This commit is contained in:
GareArc 2026-06-10 03:25:54 -07:00
parent 57f527405d
commit f3dde631dd
No known key found for this signature in database
5 changed files with 125 additions and 39 deletions

View File

@ -1,3 +1,4 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import type { ErrorCodeValue, ExitCodeValue } from './codes'
import type { ErrorEnvelope, PrintableError } from './format'
import { ErrorCode, exitFor } from './codes'
@ -83,6 +84,7 @@ type HttpClientErrorOptions = BaseErrorOptions & {
readonly method?: string
readonly url?: string
readonly rawResponse?: string
readonly serverError?: ErrorBody
}
export class HttpClientError extends BaseError {
@ -90,6 +92,7 @@ export class HttpClientError extends BaseError {
readonly method?: string
readonly url?: string
readonly rawResponse?: string
readonly serverError?: ErrorBody
constructor(opts: HttpClientErrorOptions) {
super(opts)
@ -97,6 +100,7 @@ export class HttpClientError extends BaseError {
this.method = opts.method
this.url = opts.url
this.rawResponse = opts.rawResponse
this.serverError = opts.serverError
}
override toEnvelope(): ErrorEnvelope {
@ -109,6 +113,8 @@ export class HttpClientError extends BaseError {
envelope.error.url = this.url
if (this.rawResponse !== undefined)
envelope.error.raw_response = this.rawResponse
if (this.serverError !== undefined)
envelope.error.server = this.serverError
return envelope
}
@ -119,6 +125,7 @@ export class HttpClientError extends BaseError {
method: this.method,
url: this.url,
rawResponse: this.rawResponse,
serverError: this.serverError,
}
}
@ -145,4 +152,8 @@ export class HttpClientError extends BaseError {
}
return new HttpClientError({ ...this.snapshot(), rawResponse })
}
withServerError(serverError: ErrorBody): HttpClientError {
return new HttpClientError({ ...this.snapshot(), serverError })
}
}

View File

@ -1,3 +1,4 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import { isVerbose } from '@/framework/context'
import { redactBearer } from '@/http/sanitize'
import { colorEnabled, colorScheme } from '@/sys/io/color'
@ -16,6 +17,7 @@ export type ErrorEnvelope = {
method?: string
url?: string
raw_response?: string
server?: ErrorBody
}
}

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { isHttpClientError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { classifyResponse } from './error-mapper'
function res(status: number, body: unknown): Response {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
const req = new Request('https://dify.test/openapi/v1/apps')
describe('classifyResponse — canonical ErrorBody', () => {
it('attaches the parsed body whole as serverError', async () => {
const body = {
code: 'invalid_param',
message: 'Request validation failed',
status: 422,
hint: 'check the page parameter',
details: [{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }],
}
const err = await classifyResponse(req, res(422, body))
expect(isHttpClientError(err)).toBe(true)
if (!isHttpClientError(err))
return
expect(err.serverError).toEqual(body)
expect(err.message).toBe('Request validation failed')
expect(err.code).toBe(ErrorCode.Server4xxOther)
})
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
const err = await classifyResponse(req, res(401, {
code: 'unauthorized',
message: 'session expired or revoked',
status: 401,
}))
expect(err.code).toBe(ErrorCode.AuthExpired)
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
})
it('unknown future server code is data, not behavior — status bucket decides', async () => {
const err = await classifyResponse(req, res(409, {
code: 'some_future_code',
message: 'nope',
status: 409,
}))
expect(err.code).toBe(ErrorCode.Server4xxOther)
if (isHttpClientError(err))
expect(err.serverError?.code).toBe('some_future_code')
})
})
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
it('non-JSON body yields no serverError, classification by status', async () => {
const err = await classifyResponse(req, res(502, '<html>bad gateway</html>'))
expect(err.code).toBe(ErrorCode.Server5xx)
if (isHttpClientError(err))
expect(err.serverError).toBeUndefined()
})
it('RFC 8628 string error field yields no serverError and a generic message', async () => {
const err = await classifyResponse(req, res(400, { error: 'slow_down' }))
expect(err.message).toBe('request failed (HTTP 400)')
if (isHttpClientError(err))
expect(err.serverError).toBeUndefined()
})
})

View File

@ -1,70 +1,70 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { redactBearer } from './sanitize'
type WireFields = {
code?: string
message?: string
hint?: string
}
const AUTH_EXPIRED_MESSAGE = 'session expired or revoked'
const AUTH_LOGIN_HINT = 'run \'difyctl auth login\' to sign in again'
type WireEnvelope = WireFields & {
error?: WireFields
}
async function readBody(response: Response): Promise<{ raw: string, parsed?: WireEnvelope }> {
let raw = ''
try {
raw = await response.text()
}
catch {
return { raw: '' }
}
function parseServerError(raw: string): ErrorBody | undefined {
if (raw === '')
return { raw }
return undefined
let parsed: unknown
try {
return { raw, parsed: JSON.parse(raw) as WireEnvelope }
parsed = JSON.parse(raw)
}
catch {
return { raw }
return undefined
}
const result = zErrorBody.safeParse(parsed)
return result.success ? result.data : undefined
}
export async function classifyResponse(request: Request, response: Response): Promise<BaseError> {
const { parsed, raw } = await readBody(response.clone())
const wire: WireFields = parsed?.error ?? parsed ?? {}
let raw = ''
try {
raw = await response.clone().text()
}
catch {
// ignore read errors; raw stays ''
}
const serverError = parseServerError(raw)
const status = response.status
const url = redactBearer(response.url || request.url)
const method = request.method
if (status === 401) {
return HttpClientError.from(newError(
const base = HttpClientError.from(newError(
ErrorCode.AuthExpired,
wire.message ?? 'session expired or revoked',
serverError?.message ?? AUTH_EXPIRED_MESSAGE,
))
.withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again')
.withHint(AUTH_LOGIN_HINT)
.withHttpStatus(status)
.withRequest(method, url)
return serverError !== undefined ? base.withServerError(serverError) : base
}
if (status >= 500) {
return HttpClientError.from(newError(
const base = HttpClientError.from(newError(
ErrorCode.Server5xx,
wire.message ?? `server error (HTTP ${status})`,
serverError?.message ?? `server error (HTTP ${status})`,
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
return serverError !== undefined ? base.withServerError(serverError) : base
}
const err = HttpClientError.from(newError(
const base = HttpClientError.from(newError(
ErrorCode.Server4xxOther,
wire.message ?? `request failed (HTTP ${status})`,
serverError?.message ?? `request failed (HTTP ${status})`,
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
return wire.hint !== undefined ? err.withHint(wire.hint) : err
return serverError !== undefined ? base.withServerError(serverError) : base
}
export function classifyTransportError(err: unknown): BaseError {

View File

@ -33,8 +33,8 @@ describe('createOpenApiClient error mapping', () => {
await stub?.stop()
})
it('recovers Dify message + hint from a top-level 4xx envelope', async () => {
stub = await startStubServer(cap => jsonResponder(403, { message: 'no access', hint: 'ask an admin' }, cap))
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
stub = await startStubServer(cap => jsonResponder(403, { code: 'access_denied', message: 'no access', status: 403 }, cap))
const orpc = orpcClient(stub.url)
const caught = await catchErr(() => orpc.account.get())
@ -44,7 +44,6 @@ describe('createOpenApiClient error mapping', () => {
expect(caught.code).toBe(ErrorCode.Server4xxOther)
expect(caught.httpStatus).toBe(403)
expect(caught.message).toBe('no access')
expect(caught.hint).toBe('ask an admin')
// Parity with the transport path: the migrated endpoint's error keeps the request
// method/url and the raw body, so formatted errors still print the `request:` line
// and the raw-response dump (not just message/hint).
@ -54,8 +53,8 @@ describe('createOpenApiClient error mapping', () => {
}
})
it('recovers from a nested { error: { message, hint } } envelope and keeps the auth code on 401', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: { message: 'expired', hint: 'relogin' } }, cap))
it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => {
stub = await startStubServer(cap => jsonResponder(401, { code: 'unauthorized', message: 'expired', status: 401 }, cap))
const orpc = orpcClient(stub.url)
const caught = await catchErr(() => orpc.account.get())
@ -65,11 +64,10 @@ describe('createOpenApiClient error mapping', () => {
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.httpStatus).toBe(401)
expect(caught.message).toBe('expired')
expect(caught.hint).toBe('relogin')
}
})
it('falls back to the default auth-login hint when the body carries none', async () => {
it('uses CLI default auth-login hint for non-conforming 401 body', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
const orpc = orpcClient(stub.url)
@ -82,8 +80,8 @@ describe('createOpenApiClient error mapping', () => {
}
})
it('maps 5xx to Server5xx', async () => {
stub = await startStubServer(cap => jsonResponder(503, { message: 'down for maintenance' }, cap))
it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => {
stub = await startStubServer(cap => jsonResponder(503, { code: 'service_unavailable', message: 'down for maintenance', status: 503 }, cap))
const orpc = orpcClient(stub.url)
const caught = await catchErr(() => orpc.account.get())