refactor: improve network error and allow verbose output (#36923)

This commit is contained in:
Yunlu Wen
2026-06-02 18:43:40 +08:00
committed by GitHub
parent b682591c7a
commit f19679b217
28 changed files with 339 additions and 231 deletions
+5 -1
View File
@@ -4,4 +4,8 @@ node_modules/
*.tsbuildinfo
.vitest-cache/
docs/specs/
context/
context/
test/**/*.ts.map
test/**/*.js.map
test/**/*.js
test/**/*.d.ts
+2 -2
View File
@@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base'
import { isHttpClientError } from '@/errors/base'
import { AccountSessionsClient } from './account-sessions.js'
const LIST_BODY = { page: 1, limit: 100, total: 0, has_more: false, data: [] }
@@ -70,7 +70,7 @@ describe('AccountSessionsClient.revoke', () => {
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap))
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 404,
err => isHttpClientError(err) && err.httpStatus === 404,
)
})
+2 -2
View File
@@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base'
import { isHttpClientError } from '@/errors/base'
import { AccountClient } from './account.js'
function makeClient(host: string): AccountClient {
@@ -35,7 +35,7 @@ describe('AccountClient.get', () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).get()).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 401,
err => isHttpClientError(err) && err.httpStatus === 401,
)
})
})
+2 -2
View File
@@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base'
import { isHttpClientError } from '@/errors/base'
import { AppsClient } from './apps.js'
const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] }
@@ -74,7 +74,7 @@ describe('AppsClient.list', () => {
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 403,
err => isHttpClientError(err) && err.httpStatus === 403,
)
})
})
+2 -2
View File
@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base'
import { isHttpClientError } from '@/errors/base'
import { FileUploadClient } from './file-upload.js'
const UPLOADED = {
@@ -70,7 +70,7 @@ describe('FileUploadClient.upload', () => {
stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap))
await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 413,
err => isHttpClientError(err) && err.httpStatus === 413,
)
})
})
+7 -7
View File
@@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base'
import { isHttpClientError } from '@/errors/base'
import { MembersClient } from './members.js'
import { WorkspacesClient } from './workspaces.js'
@@ -62,7 +62,7 @@ describe('MembersClient.list', () => {
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 403,
err => isHttpClientError(err) && err.httpStatus === 403,
)
})
@@ -70,7 +70,7 @@ describe('MembersClient.list', () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 404,
err => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
@@ -117,7 +117,7 @@ describe('MembersClient.invite', () => {
await expect(
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400)
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
})
})
@@ -142,7 +142,7 @@ describe('MembersClient.remove', () => {
stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap))
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 400,
err => isHttpClientError(err) && err.httpStatus === 400,
)
})
})
@@ -170,7 +170,7 @@ describe('MembersClient.updateRole', () => {
await expect(
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400)
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
})
})
@@ -209,7 +209,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
await expect(client.switch('ws-x')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 404,
err => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
+4 -4
View File
@@ -1,5 +1,5 @@
import type { HttpClient } from '@/http/types'
import { BaseError } from '@/errors/base'
import { BaseError, HttpClientError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
export const DEFAULT_CLIENT_ID = 'difyctl'
@@ -80,7 +80,7 @@ export class DeviceFlowApi {
if (res.status === 404)
throw versionSkew()
if (!res.ok) {
throw new BaseError({
throw new HttpClientError({
code: ErrorCode.Server4xxOther,
message: `device/code: HTTP ${res.status}`,
httpStatus: res.status,
@@ -133,8 +133,8 @@ export class DeviceFlowApi {
}
}
function versionSkew(): BaseError {
return new BaseError({
function versionSkew(): HttpClientError {
return new HttpClientError({
code: ErrorCode.UnsupportedEndpoint,
message: 'this Dify host does not implement the OAuth device flow',
httpStatus: 404,
+3 -3
View File
@@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base'
import { isHttpClientError } from '@/errors/base'
import { WorkspacesClient } from './workspaces.js'
// WorkspacesClient.switch is covered in members.test.ts; this file covers list().
@@ -30,14 +30,14 @@ describe('WorkspacesClient.list', () => {
expect(stub.captured.method).toBe('GET')
expect(stub.captured.url).toBe('/openapi/v1/workspaces')
expect(res.workspaces[0].id).toBe('ws-1')
expect(res.workspaces[0]?.id).toBe('ws-1')
})
it('maps 401 to a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).list()).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 401,
err => isHttpClientError(err) && err.httpStatus === 401,
)
})
})
+2 -2
View File
@@ -357,14 +357,14 @@ describe('runApp', () => {
// warm cache with successful run
await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache },
{ active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache },
)
expect(cache.get(mock.url, 'app-1')).toBeDefined()
mock.setScenario('run-422-stale')
const err = await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: bundle(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
{ active: active(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
).catch((e: unknown) => e)
expect(err).toMatchObject({ code: 'server_4xx_other', httpStatus: 422 })
expect((err as { hint?: string }).hint).toMatch(/cache cleared/)
+2 -2
View File
@@ -7,7 +7,7 @@ import { AppRunClient } from '@/api/app-run'
import { AppsClient } from '@/api/apps'
import { FileUploadClient } from '@/api/file-upload'
import { pickStrategy } from '@/commands/run/app/_strategies/index'
import { BaseError } from '@/errors/base'
import { BaseError, HttpClientError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { getEnv, processExit } from '@/sys/index'
import { FieldInfo } from '@/types/app-meta'
@@ -87,7 +87,7 @@ export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<voi
await executeRun(opts, deps, meta, wsId)
}
catch (err) {
if (err instanceof BaseError && err.httpStatus === 422) {
if (err instanceof HttpClientError && err.httpStatus === 422) {
await meta.invalidate(opts.appId)
throw err.withHint('app metadata cache cleared — if the app was recently republished, run the command again')
}
@@ -1,3 +1,4 @@
import type { HttpClientError } from '@/errors/base'
import type { SseEvent } from '@/http/sse'
import { describe, expect, it } from 'vitest'
import { collect, collectorFor, decodeStreamError, HitlPauseError } from './sse-collector'
@@ -130,7 +131,7 @@ describe('decodeStreamError', () => {
const err = decodeStreamError(enc.encode(JSON.stringify(env)))
expect(err.message).toBe(inner.args.description)
expect(err.code).toBe('server_4xx_other')
expect(err.httpStatus).toBe(400)
expect((err as HttpClientError).httpStatus).toBe(400)
})
it('unwraps openapi-v1 invoke-error: falls back to inner.message when no args.description', () => {
+3 -3
View File
@@ -1,6 +1,6 @@
import type { BaseError } from '@/errors/base'
import type { SseEvent } from '@/http/sse'
import { newError } from '@/errors/base'
import { HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { RUN_MODES } from './handlers'
@@ -173,9 +173,9 @@ export function decodeStreamError(data: Uint8Array): BaseError {
const code = env.status !== undefined && env.status > 0 && env.status < 500
? ErrorCode.Server4xxOther
: ErrorCode.Server5xx
let err = newError(code, message)
const err = newError(code, message)
if (env.status !== undefined && env.status > 0)
err = err.withHttpStatus(env.status)
return HttpClientError.from(err).withHttpStatus(env.status)
return err
}
+57 -5
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { BaseError, isBaseError, newError, unknownError } from './base'
import { BaseError, HttpClientError, isBaseError, newError, unknownError } from './base'
import { ErrorCode, ExitCode } from './codes'
describe('BaseError', () => {
it('captures code, message, optional fields', () => {
const err = new BaseError({
const err = new HttpClientError({
code: ErrorCode.AuthExpired,
message: 'session expired',
hint: 'run difyctl auth login',
@@ -30,7 +30,6 @@ describe('BaseError', () => {
expect(newError(ErrorCode.AuthExpired, 'x').exit()).toBe(ExitCode.Auth)
expect(newError(ErrorCode.UsageInvalidFlag, 'x').exit()).toBe(ExitCode.Usage)
expect(newError(ErrorCode.VersionSkew, 'x').exit()).toBe(ExitCode.VersionCompat)
expect(newError(ErrorCode.NetworkDns, 'x').exit()).toBe(ExitCode.Generic)
})
it('toString without hint formats "<code>: <message>"', () => {
@@ -56,7 +55,7 @@ describe('BaseError', () => {
it('withHttpStatus + withRequest + wrap chain immutably', () => {
const cause = new Error('underlying')
const built = newError(ErrorCode.NetworkTimeout, 'timed out')
const built = HttpClientError.from(newError(ErrorCode.NetworkConnection, 'timed out'))
.withHttpStatus(504)
.withRequest('POST', 'https://x/y')
.wrap(cause)
@@ -68,7 +67,7 @@ describe('BaseError', () => {
it('wrap exposes cause via standard Error.cause property', () => {
const cause = new Error('underlying failure')
const wrapped = newError(ErrorCode.NetworkTimeout, 'timed out').wrap(cause)
const wrapped = newError(ErrorCode.NetworkConnection, 'timed out').wrap(cause)
expect(wrapped.cause).toBe(cause)
})
@@ -86,3 +85,56 @@ describe('BaseError', () => {
expect(err.cause).toBe(cause)
})
})
describe('error envelope', () => {
it('emits required fields only when minimal', () => {
const err = newError(ErrorCode.Unknown, 'boom')
expect(err.toEnvelope()).toEqual({
error: { code: 'unknown', message: 'boom' },
})
})
it('includes hint / http_status / method / url when present', () => {
const err = HttpClientError.from(newError(ErrorCode.NetworkConnection, 'timed out'))
.withHint('check your network')
.withHttpStatus(504)
.withRequest('POST', 'https://api.dify.ai/v1/x')
expect(err.toEnvelope()).toEqual({
error: {
code: 'network_connection',
message: 'timed out',
hint: 'check your network',
http_status: 504,
method: 'POST',
url: 'https://api.dify.ai/v1/x',
},
})
})
it('renderEnvelope returns a single-line JSON string', () => {
const err = newError(ErrorCode.AuthExpired, 'session expired')
.withHint('run difyctl auth login')
const out = JSON.stringify(err.toEnvelope())
expect(out).toBe(
'{"error":{"code":"auth_expired","message":"session expired","hint":"run difyctl auth login"}}',
)
expect(out).not.toContain('\n')
})
it('renderEnvelope output round-trips through JSON.parse to an ErrorEnvelope shape', () => {
const err = newError(ErrorCode.UsageInvalidFlag, 'bad flag').withHint('see --help')
const parsed = JSON.parse(JSON.stringify(err.toEnvelope()))
expect(parsed).toEqual({
error: { code: 'usage_invalid_flag', message: 'bad flag', hint: 'see --help' },
})
})
it('omits undefined optional fields entirely (no `hint: null`)', () => {
const err = newError(ErrorCode.Server5xx, 'upstream broke')
const envelope = err.toEnvelope()
expect(envelope.error).not.toHaveProperty('hint')
expect(envelope.error).not.toHaveProperty('http_status')
expect(envelope.error).not.toHaveProperty('method')
expect(envelope.error).not.toHaveProperty('url')
})
})
+91 -24
View File
@@ -1,31 +1,24 @@
import type { ErrorCodeValue, ExitCodeValue } from './codes'
import type { ErrorEnvelope, PrintableError } from './format'
import { ErrorCode, exitFor } from './codes'
export type BaseErrorOptions = {
readonly code: ErrorCodeValue
readonly message: string
readonly hint?: string
readonly httpStatus?: number
readonly method?: string
readonly url?: string
readonly cause?: unknown
}
export class BaseError extends Error {
export class BaseError extends Error implements PrintableError {
readonly code: ErrorCodeValue
readonly hint?: string
readonly httpStatus?: number
readonly method?: string
readonly url?: string
constructor(opts: BaseErrorOptions) {
super(opts.message, opts.cause === undefined ? undefined : { cause: opts.cause })
this.name = 'BaseError'
this.code = opts.code
this.hint = opts.hint
this.httpStatus = opts.httpStatus
this.method = opts.method
this.url = opts.url
Object.setPrototypeOf(this, new.target.prototype)
}
@@ -39,30 +32,31 @@ export class BaseError extends Error {
: `${this.code}: ${this.message}`
}
withHint(hint: string): BaseError {
return new BaseError({ ...this.snapshot(), hint })
toEnvelope(): ErrorEnvelope {
const payload: ErrorEnvelope['error'] = {
code: this.code,
message: this.message,
}
if (this.hint !== undefined)
payload.hint = this.hint
return { error: payload }
}
withHttpStatus(httpStatus: number): BaseError {
return new BaseError({ ...this.snapshot(), httpStatus })
withHint<T extends BaseError>(this: T, hint: string): T {
const Ctor = this.constructor as new (opts: BaseErrorOptions) => T
return new Ctor({ ...this.snapshot(), hint })
}
withRequest(method: string, url: string): BaseError {
return new BaseError({ ...this.snapshot(), method, url })
wrap<T extends BaseError>(this: T, cause: unknown): T {
const Ctor = this.constructor as new (opts: BaseErrorOptions) => T
return new Ctor({ ...this.snapshot(), cause })
}
wrap(cause: unknown): BaseError {
return new BaseError({ ...this.snapshot(), cause })
}
private snapshot(): BaseErrorOptions {
protected snapshot(): BaseErrorOptions {
return {
code: this.code,
message: this.message,
hint: this.hint,
httpStatus: this.httpStatus,
method: this.method,
url: this.url,
cause: this.cause,
}
}
@@ -76,6 +70,79 @@ export function isBaseError(value: unknown): value is BaseError {
return value instanceof BaseError
}
export function isHttpClientError(value: unknown): value is HttpClientError {
return value instanceof HttpClientError
}
export function unknownError(message: string, cause?: unknown): BaseError {
return new BaseError({ code: ErrorCode.Unknown, message, cause })
}
type HttpClientErrorOptions = BaseErrorOptions & {
readonly httpStatus?: number
readonly method?: string
readonly url?: string
readonly rawResponse?: string
}
export class HttpClientError extends BaseError {
readonly httpStatus?: number
readonly method?: string
readonly url?: string
readonly rawResponse?: string
constructor(opts: HttpClientErrorOptions) {
super(opts)
this.httpStatus = opts.httpStatus
this.method = opts.method
this.url = opts.url
this.rawResponse = opts.rawResponse
}
override toEnvelope(): ErrorEnvelope {
const envelope = super.toEnvelope()
if (this.httpStatus !== undefined)
envelope.error.http_status = this.httpStatus
if (this.method !== undefined)
envelope.error.method = this.method
if (this.url !== undefined)
envelope.error.url = this.url
if (this.rawResponse !== undefined)
envelope.error.raw_response = this.rawResponse
return envelope
}
protected override snapshot(): HttpClientErrorOptions {
return {
...super.snapshot(),
httpStatus: this.httpStatus,
method: this.method,
url: this.url,
rawResponse: this.rawResponse,
}
}
public static from(error: BaseError): HttpClientError {
return new HttpClientError({
code: error.code,
message: error.message,
hint: error.hint,
cause: error.cause,
})
}
withHttpStatus(httpStatus: number): HttpClientError {
return new HttpClientError({ ...this.snapshot(), httpStatus })
}
withRequest(method: string, url: string): HttpClientError {
return new HttpClientError({ ...this.snapshot(), method, url })
}
withRawResponse(rawResponse: string): HttpClientError {
if (!rawResponse) {
return this
}
return new HttpClientError({ ...this.snapshot(), rawResponse })
}
}
-2
View File
@@ -42,8 +42,6 @@ describe('error codes', () => {
[ErrorCode.UsageMissingArg, ExitCode.Usage],
[ErrorCode.ConfigInvalidKey, ExitCode.Usage],
[ErrorCode.ConfigInvalidValue, ExitCode.Usage],
[ErrorCode.NetworkTimeout, ExitCode.Generic],
[ErrorCode.NetworkDns, ExitCode.Generic],
[ErrorCode.Server5xx, ExitCode.Generic],
[ErrorCode.Server4xxOther, ExitCode.Generic],
[ErrorCode.ClientError, ExitCode.Generic],
+2 -4
View File
@@ -11,8 +11,7 @@ export const ErrorCode = {
UsageMissingArg: 'usage_missing_arg',
ConfigInvalidKey: 'config_invalid_key',
ConfigInvalidValue: 'config_invalid_value',
NetworkTimeout: 'network_timeout',
NetworkDns: 'network_dns',
NetworkConnection: 'network_connection',
Server5xx: 'server_5xx',
Server4xxOther: 'server_4xx_other',
ClientError: 'client_error',
@@ -45,8 +44,7 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
usage_missing_arg: ExitCode.Usage,
config_invalid_key: ExitCode.Usage,
config_invalid_value: ExitCode.Usage,
network_timeout: ExitCode.Generic,
network_dns: ExitCode.Generic,
network_connection: ExitCode.Generic,
server_5xx: ExitCode.Generic,
server_4xx_other: ExitCode.Generic,
client_error: ExitCode.Generic,
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import { newError } from './base'
import { ErrorCode } from './codes'
import { renderEnvelope, toEnvelope } from './envelope'
describe('error envelope', () => {
it('emits required fields only when minimal', () => {
const err = newError(ErrorCode.Unknown, 'boom')
expect(toEnvelope(err)).toEqual({
error: { code: 'unknown', message: 'boom' },
})
})
it('includes hint / http_status / method / url when present', () => {
const err = newError(ErrorCode.NetworkTimeout, 'timed out')
.withHint('check your network')
.withHttpStatus(504)
.withRequest('POST', 'https://api.dify.ai/v1/x')
expect(toEnvelope(err)).toEqual({
error: {
code: 'network_timeout',
message: 'timed out',
hint: 'check your network',
http_status: 504,
method: 'POST',
url: 'https://api.dify.ai/v1/x',
},
})
})
it('renderEnvelope returns a single-line JSON string', () => {
const err = newError(ErrorCode.AuthExpired, 'session expired')
.withHint('run difyctl auth login')
const out = renderEnvelope(err)
expect(out).toBe(
'{"error":{"code":"auth_expired","message":"session expired","hint":"run difyctl auth login"}}',
)
expect(out).not.toContain('\n')
})
it('renderEnvelope output round-trips through JSON.parse to an ErrorEnvelope shape', () => {
const err = newError(ErrorCode.UsageInvalidFlag, 'bad flag').withHint('see --help')
const parsed = JSON.parse(renderEnvelope(err))
expect(parsed).toEqual({
error: { code: 'usage_invalid_flag', message: 'bad flag', hint: 'see --help' },
})
})
it('omits undefined optional fields entirely (no `hint: null`)', () => {
const err = newError(ErrorCode.Server5xx, 'upstream broke')
const envelope = toEnvelope(err)
expect(envelope.error).not.toHaveProperty('hint')
expect(envelope.error).not.toHaveProperty('http_status')
expect(envelope.error).not.toHaveProperty('method')
expect(envelope.error).not.toHaveProperty('url')
})
})
-32
View File
@@ -1,32 +0,0 @@
import type { BaseError } from './base'
export type ErrorEnvelope = {
error: {
code: string
message: string
hint?: string
http_status?: number
method?: string
url?: string
}
}
export function toEnvelope(err: BaseError): ErrorEnvelope {
const payload: ErrorEnvelope['error'] = {
code: err.code,
message: err.message,
}
if (err.hint !== undefined)
payload.hint = err.hint
if (err.httpStatus !== undefined)
payload.http_status = err.httpStatus
if (err.method !== undefined)
payload.method = err.method
if (err.url !== undefined)
payload.url = err.url
return { error: payload }
}
export function renderEnvelope(err: BaseError): string {
return JSON.stringify(toEnvelope(err))
}
+46 -14
View File
@@ -1,26 +1,58 @@
import type { BaseError } from './base'
import { isVerbose } from '@/framework/context'
import { redactBearer } from '@/http/sanitize'
import { colorEnabled, colorScheme } from '@/sys/io/color'
import { renderEnvelope } from './envelope'
export type FormatErrorOptions = {
readonly format?: string
readonly isErrTTY?: boolean
}
export function formatErrorForCli(err: BaseError, opts: FormatErrorOptions = {}): string {
if (opts.format === 'json')
return renderEnvelope(err)
return humanError(err, opts.isErrTTY ?? false)
export type ErrorEnvelope = {
error: {
code: string
message: string
hint?: string
http_status?: number
method?: string
url?: string
raw_response?: string
}
}
function humanError(err: BaseError, isErrTTY: boolean): string {
export type PrintableError = {
toEnvelope: () => ErrorEnvelope
}
export function formatErrorForCli(err: PrintableError, opts: FormatErrorOptions = {}): string {
const env = err.toEnvelope()
if (opts.format === 'json')
return renderEnvelope(env)
return renderHuman(env, opts.isErrTTY ?? false)
}
function renderEnvelope(env: ErrorEnvelope): string {
const raw = env.error.raw_response
if (raw === undefined)
return JSON.stringify(env)
if (!isVerbose()) {
delete env.error.raw_response
return JSON.stringify(env)
}
env.error.raw_response = redactBearer(raw)
return JSON.stringify(env)
}
function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string {
const cs = colorScheme(colorEnabled(isErrTTY))
const lines: string[] = [`${err.code}: ${err.message}`]
if (err.hint !== undefined)
lines.push(`${cs.magenta('hint:')} ${cs.cyan(err.hint)}`)
if (err.method !== undefined && err.url !== undefined)
lines.push(`request: ${err.method} ${err.url}`)
if (err.httpStatus !== undefined)
lines.push(`http_status: ${err.httpStatus}`)
const e = env.error
const lines: string[] = [`${e.code}: ${e.message}`]
if (e.hint !== undefined)
lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`)
if (e.method !== undefined && e.url !== undefined)
lines.push(`request: ${e.method} ${e.url}`)
if (e.http_status !== undefined)
lines.push(`http_status: ${e.http_status}`)
if (isVerbose() && e.raw_response)
lines.push(`raw_response: ${redactBearer(e.raw_response)}`)
return lines.join('\n')
}
+7 -1
View File
@@ -1,6 +1,7 @@
import type { CommandOutput } from './output'
import type { ArgDefinition, FlagDefinition, ICommand, InferArgs, InferFlags, OptionalArgValueType } from './types'
import { parseArgv } from './flags'
import { setVerbose } from './context'
import { hasBooleanFlag, parseArgv, VERBOSE_CHAR, VERBOSE_FLAG } from './flags'
export type CommandConstructor = {
new(): Command
@@ -28,11 +29,16 @@ type ParseResult<C extends CommandConstructor> = {
export abstract class Command implements ICommand {
static description?: string
static flags: Record<string, FlagDefinition<OptionalArgValueType>> = {}
static args: Record<string, ArgDefinition<string | undefined>> = {}
static examples: string[] = []
abstract run(argv: string[]): Promise<CommandOutput | void>
processGlobalFlags(argv: readonly string[]): void {
setVerbose(hasBooleanFlag(argv, VERBOSE_FLAG, VERBOSE_CHAR))
}
protected parse<C extends CommandConstructor>(ctor: C, argv: string[]): ParseResult<C> {
const meta = {
flags: ctor.flags ?? {},
+15
View File
@@ -0,0 +1,15 @@
type CommandContext = {
verbose: boolean
}
const commandContext: CommandContext = {
verbose: false,
}
export function setVerbose(verbose: boolean): void {
commandContext.verbose = verbose
}
export function isVerbose(): boolean {
return commandContext.verbose
}
+42 -14
View File
@@ -1,6 +1,24 @@
import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types'
import { UnsupportedArgValueError } from './errors'
export const VERBOSE_FLAG = 'verbose'
export const VERBOSE_CHAR = 'v'
export const Flags = {
string: stringFlag,
stringArray: stringRepeatedFlag,
boolean: booleanFlag,
integer: integerFlag,
outputFormat: outputFormatFlag,
}
const GLOBAL_FLAGS: Record<string, FlagDefinition> = {
[VERBOSE_FLAG]: Flags.boolean({
char: VERBOSE_CHAR,
description: 'enable verbose output',
}),
}
function stringFlag<const Opts extends {
description: string
char?: string
@@ -48,14 +66,6 @@ function integerFlag<const Opts extends { description: string, char?: string, de
return { type: 'integer', ...opts } as FlagDefinition<Opts extends { default: number } ? number : number | undefined>
}
export const Flags = {
string: stringFlag,
stringArray: stringRepeatedFlag,
boolean: booleanFlag,
integer: integerFlag,
outputFormat: outputFormatFlag,
}
function stringArg<const Opts extends { description: string, required?: boolean }>(
opts: Opts,
): ArgDefinition<Opts extends { required: true } ? string : string | undefined> {
@@ -99,8 +109,8 @@ function accumulateFlagValue(flags: ParsedFlags, name: string, value: string | b
}
}
function resolveByChar(char: string, meta: CommandMeta): [name: string, def: FlagDefinition] | undefined {
for (const [name, def] of Object.entries(meta.flags)) {
function resolveByChar(char: string, flags: Record<string, FlagDefinition>): [name: string, def: FlagDefinition] | undefined {
for (const [name, def] of Object.entries(flags)) {
if (def.char === char)
return [name, def]
}
@@ -115,12 +125,12 @@ function validateFlagOptions(name: string, raw: string, def: FlagDefinition): vo
type ResolvedFlag = { name: string, def: FlagDefinition, label: string, inlineRaw: string | undefined }
function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
function resolveToken(token: string, flags: Record<string, FlagDefinition>): ResolvedFlag | null {
if (token.startsWith('--')) {
const eqIdx = token.indexOf('=')
const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2)
const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined
const def = meta.flags[name]
const def = flags[name]
if (!def)
throw new Error(`unknown flag: --${name}`)
return { name, def, label: `--${name}`, inlineRaw }
@@ -128,7 +138,7 @@ function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
if (token.length === 2 && token[1] !== undefined) {
const char = token[1]
const resolved = resolveByChar(char, meta)
const resolved = resolveByChar(char, flags)
if (!resolved)
throw new Error(`unknown flag: -${char}`)
const [name, def] = resolved
@@ -138,6 +148,21 @@ function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
return null
}
// Scans argv for a boolean flag without throwing on unknown tokens, so it is safe
// to call before the command-specific flag set is known (e.g. global flags).
export function hasBooleanFlag(argv: readonly string[], name: string, char?: string): boolean {
for (const token of argv) {
if (token === '--')
break
if (token === `--${name}` || token === `--${name}=true` || token === `--${name}=1`)
return true
if (char !== undefined && token === `-${char}`)
return true
}
return false
}
export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } {
const flags: ParsedFlags = {}
const positional: string[] = []
@@ -159,7 +184,10 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P
continue
}
const resolved = resolveToken(token, meta)
const resolved = resolveToken(token, {
...meta.flags,
...GLOBAL_FLAGS, // pass global flags to prevent unknown flag error
})
if (!resolved) {
positional.push(token)
continue
+4 -4
View File
@@ -1,7 +1,7 @@
import type { CommandConstructor } from './command'
import type { CommandTree } from './registry'
import { describe, expect, it } from 'vitest'
import { BaseError, newError } from '@/errors/base'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode, ExitCode } from '@/errors/codes'
import { Command } from './command'
import { run, sniffOutputFormat } from './run'
@@ -171,7 +171,7 @@ describe('run() catch routing', () => {
it('routes Server5xx error with http_status line and generic exit', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.Server5xx, 'upstream boom').withHttpStatus(502)
throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')).withHttpStatus(502)
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
@@ -182,7 +182,7 @@ describe('run() catch routing', () => {
it('renders request line and http_status when both are present', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.Server5xx, 'upstream boom')
throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom'))
.withRequest('GET', 'https://api.dify.ai/v1/me')
.withHttpStatus(502)
}
@@ -197,7 +197,7 @@ describe('run() catch routing', () => {
it('serializes method and url in JSON envelope', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.Server4xxOther, 'not found')
throw HttpClientError.from(newError(ErrorCode.Server4xxOther, 'not found'))
.withRequest('GET', 'https://api.dify.ai/v1/apps/x')
.withHttpStatus(404)
}
+4 -1
View File
@@ -45,7 +45,10 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> {
if (typeof Ctor.deprecated === 'string' && Ctor.deprecated.length > 0)
process.stderr.write(`deprecated: ${Ctor.deprecated}\n`)
const cmd = new Ctor()
const output = await cmd.run(argv.slice(resolved.path.length))
const commandArgv = argv.slice(resolved.path.length)
cmd.processGlobalFlags(commandArgv)
const output = await cmd.run(commandArgv)
if (output !== undefined)
process.stdout.write(stringifyOutput(output))
}
+9 -9
View File
@@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net'
import * as http from 'node:http'
import { startMock } from '@test/fixtures/dify-mock/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isBaseError } from '@/errors/base'
import { isBaseError, isHttpClientError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { openAPIBase } from '@/util/host'
import { createHttpClient } from './client.js'
@@ -116,8 +116,8 @@ describe('http client', () => {
await client.get('workspaces')
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught)) {
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.httpStatus).toBe(401)
expect(caught.method).toBe('GET')
@@ -138,8 +138,8 @@ describe('http client', () => {
await client.get('workspaces')
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught)) {
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.Server5xx)
expect(caught.httpStatus).toBe(503)
}
@@ -187,8 +187,8 @@ describe('http client', () => {
await client.get('apps/nope/describe')
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught))
expect(caught.code).toBe(ErrorCode.Server4xxOther)
})
@@ -205,8 +205,8 @@ describe('http client', () => {
await client.get('workspaces')
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught))
expect(caught.httpStatus).toBe(429)
})
+4 -3
View File
@@ -9,6 +9,7 @@ import type {
RequestOptions,
ResolvedOptions,
} from './types.js'
import { isVerbose } from '@/framework/context'
import { userAgent as defaultUserAgent } from '@/version/info'
import { buildBody } from './body.js'
import { classifyResponse } from './error-mapper.js'
@@ -133,11 +134,11 @@ async function dispatch(state: ClientState, path: string, opts: RequestOptions,
await runHooks(state.hooks.onRequest, ctx)
// `dispatcher` is an undici extension to RequestInit, not in @types/node's fetch
// signature — hence the local type. Carries proxy routing when a proxy env var is set.
const init: RequestInit & { dispatcher?: unknown } = { signal }
const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal }
if (state.dispatcher !== undefined)
init.dispatcher = state.dispatcher
if (isVerbose())
init.verbose = true
try {
ctx.response = await fetch(ctx.request, init)
+19 -24
View File
@@ -1,5 +1,4 @@
import type { BaseError } from '@/errors/base'
import { newError } from '@/errors/base'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { redactBearer } from './sanitize'
@@ -32,54 +31,50 @@ async function readBody(response: Response): Promise<{ raw: string, parsed?: Wir
}
export async function classifyResponse(request: Request, response: Response): Promise<BaseError> {
const { parsed } = await readBody(response.clone())
const { parsed, raw } = await readBody(response.clone())
const wire: WireFields = parsed?.error ?? parsed ?? {}
const status = response.status
const url = redactBearer(response.url || request.url)
const method = request.method
if (status === 401) {
return newError(
return HttpClientError.from(newError(
ErrorCode.AuthExpired,
wire.message ?? 'session expired or revoked',
)
))
.withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again')
.withHttpStatus(status)
.withRequest(method, url)
}
if (status >= 500) {
return newError(
return HttpClientError.from(newError(
ErrorCode.Server5xx,
wire.message ?? `server error (HTTP ${status})`,
)
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
}
const err = newError(
const err = HttpClientError.from(newError(
ErrorCode.Server4xxOther,
wire.message ?? `request failed (HTTP ${status})`,
)
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
return wire.hint !== undefined ? err.withHint(wire.hint) : err
}
export function classifyTransportError(err: unknown): BaseError {
const message = err instanceof Error ? err.message : String(err)
const sanitized = redactBearer(message)
if (err instanceof Error && err.name === 'TimeoutError')
return newError(ErrorCode.NetworkTimeout, 'request timed out').wrap(err)
if (err instanceof Error && err.name === 'AbortError')
return newError(ErrorCode.NetworkTimeout, 'request aborted').wrap(err)
if (sanitized.toLowerCase().includes('econnrefused'))
return newError(ErrorCode.NetworkDns, 'connection refused').wrap(err)
if (sanitized.toLowerCase().includes('enotfound'))
return newError(ErrorCode.NetworkDns, 'host lookup failed').wrap(err)
if (sanitized.toLowerCase().includes('etimedout'))
return newError(ErrorCode.NetworkTimeout, 'connection timed out').wrap(err)
return newError(ErrorCode.Unknown, sanitized).wrap(err)
if (err instanceof BaseError) {
return err
}
if (!(err instanceof Error)) {
return newError(ErrorCode.Unknown, String(err)).wrap(err)
}
const sanitized = redactBearer(err.message)
// there isn't a practical way to classify network errors reliably
return newError(ErrorCode.NetworkConnection, sanitized).wrap(err)
}
+3 -6
View File
@@ -1,7 +1,6 @@
{
"extends": "@dify/tsconfig/node.json",
"compilerOptions": {
"rootDir": "src",
"moduleResolution": "bundler",
"paths": {
"@/*": [
@@ -12,12 +11,10 @@
]
},
"types": ["node"],
"declaration": true,
"declarationMap": true,
"noEmit": false,
"noEmit": true, // we already have bundlers to handle this.
"outDir": "dist",
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "test", "node_modules", "**/*.test.ts"]
"include": ["src/**/*.ts", "test/**/*.ts"], // tests must be included for typechecking
"exclude": ["node_modules", "dist"]
}