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 *.tsbuildinfo
.vitest-cache/ .vitest-cache/
docs/specs/ 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 { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base' import { isHttpClientError } from '@/errors/base'
import { AccountSessionsClient } from './account-sessions.js' import { AccountSessionsClient } from './account-sessions.js'
const LIST_BODY = { page: 1, limit: 100, total: 0, has_more: false, data: [] } 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)) jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap))
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy( 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 { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base' import { isHttpClientError } from '@/errors/base'
import { AccountClient } from './account.js' import { AccountClient } from './account.js'
function makeClient(host: string): AccountClient { function makeClient(host: string): AccountClient {
@@ -35,7 +35,7 @@ describe('AccountClient.get', () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).get()).rejects.toSatisfy( 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 { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base' import { isHttpClientError } from '@/errors/base'
import { AppsClient } from './apps.js' import { AppsClient } from './apps.js'
const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] } 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)) stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy( 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 { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base' import { isHttpClientError } from '@/errors/base'
import { FileUploadClient } from './file-upload.js' import { FileUploadClient } from './file-upload.js'
const UPLOADED = { const UPLOADED = {
@@ -70,7 +70,7 @@ describe('FileUploadClient.upload', () => {
stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap)) stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap))
await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy( 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 { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base' import { isHttpClientError } from '@/errors/base'
import { MembersClient } from './members.js' import { MembersClient } from './members.js'
import { WorkspacesClient } from './workspaces.js' import { WorkspacesClient } from './workspaces.js'
@@ -62,7 +62,7 @@ describe('MembersClient.list', () => {
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy( 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)) stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy( 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( await expect(
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }), 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)) stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap))
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy( 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( await expect(
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }), 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')) const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
await expect(client.switch('ws-x')).rejects.toSatisfy( 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 type { HttpClient } from '@/http/types'
import { BaseError } from '@/errors/base' import { BaseError, HttpClientError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes' import { ErrorCode } from '@/errors/codes'
export const DEFAULT_CLIENT_ID = 'difyctl' export const DEFAULT_CLIENT_ID = 'difyctl'
@@ -80,7 +80,7 @@ export class DeviceFlowApi {
if (res.status === 404) if (res.status === 404)
throw versionSkew() throw versionSkew()
if (!res.ok) { if (!res.ok) {
throw new BaseError({ throw new HttpClientError({
code: ErrorCode.Server4xxOther, code: ErrorCode.Server4xxOther,
message: `device/code: HTTP ${res.status}`, message: `device/code: HTTP ${res.status}`,
httpStatus: res.status, httpStatus: res.status,
@@ -133,8 +133,8 @@ export class DeviceFlowApi {
} }
} }
function versionSkew(): BaseError { function versionSkew(): HttpClientError {
return new BaseError({ return new HttpClientError({
code: ErrorCode.UnsupportedEndpoint, code: ErrorCode.UnsupportedEndpoint,
message: 'this Dify host does not implement the OAuth device flow', message: 'this Dify host does not implement the OAuth device flow',
httpStatus: 404, 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 { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '@/errors/base' import { isHttpClientError } from '@/errors/base'
import { WorkspacesClient } from './workspaces.js' import { WorkspacesClient } from './workspaces.js'
// WorkspacesClient.switch is covered in members.test.ts; this file covers list(). // 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.method).toBe('GET')
expect(stub.captured.url).toBe('/openapi/v1/workspaces') 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 () => { it('maps 401 to a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).list()).rejects.toSatisfy( 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 // warm cache with successful run
await runApp( await runApp(
{ appId: 'app-1', message: 'hi' }, { 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() expect(cache.get(mock.url, 'app-1')).toBeDefined()
mock.setScenario('run-422-stale') mock.setScenario('run-422-stale')
const err = await runApp( const err = await runApp(
{ appId: 'app-1', message: 'hi' }, { 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) ).catch((e: unknown) => e)
expect(err).toMatchObject({ code: 'server_4xx_other', httpStatus: 422 }) expect(err).toMatchObject({ code: 'server_4xx_other', httpStatus: 422 })
expect((err as { hint?: string }).hint).toMatch(/cache cleared/) 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 { AppsClient } from '@/api/apps'
import { FileUploadClient } from '@/api/file-upload' import { FileUploadClient } from '@/api/file-upload'
import { pickStrategy } from '@/commands/run/app/_strategies/index' 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 { ErrorCode } from '@/errors/codes'
import { getEnv, processExit } from '@/sys/index' import { getEnv, processExit } from '@/sys/index'
import { FieldInfo } from '@/types/app-meta' 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) await executeRun(opts, deps, meta, wsId)
} }
catch (err) { catch (err) {
if (err instanceof BaseError && err.httpStatus === 422) { if (err instanceof HttpClientError && err.httpStatus === 422) {
await meta.invalidate(opts.appId) await meta.invalidate(opts.appId)
throw err.withHint('app metadata cache cleared — if the app was recently republished, run the command again') 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 type { SseEvent } from '@/http/sse'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { collect, collectorFor, decodeStreamError, HitlPauseError } from './sse-collector' import { collect, collectorFor, decodeStreamError, HitlPauseError } from './sse-collector'
@@ -130,7 +131,7 @@ describe('decodeStreamError', () => {
const err = decodeStreamError(enc.encode(JSON.stringify(env))) const err = decodeStreamError(enc.encode(JSON.stringify(env)))
expect(err.message).toBe(inner.args.description) expect(err.message).toBe(inner.args.description)
expect(err.code).toBe('server_4xx_other') 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', () => { 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 { BaseError } from '@/errors/base'
import type { SseEvent } from '@/http/sse' import type { SseEvent } from '@/http/sse'
import { newError } from '@/errors/base' import { HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes' import { ErrorCode } from '@/errors/codes'
import { RUN_MODES } from './handlers' 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 const code = env.status !== undefined && env.status > 0 && env.status < 500
? ErrorCode.Server4xxOther ? ErrorCode.Server4xxOther
: ErrorCode.Server5xx : ErrorCode.Server5xx
let err = newError(code, message) const err = newError(code, message)
if (env.status !== undefined && env.status > 0) if (env.status !== undefined && env.status > 0)
err = err.withHttpStatus(env.status) return HttpClientError.from(err).withHttpStatus(env.status)
return err return err
} }
+57 -5
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest' 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' import { ErrorCode, ExitCode } from './codes'
describe('BaseError', () => { describe('BaseError', () => {
it('captures code, message, optional fields', () => { it('captures code, message, optional fields', () => {
const err = new BaseError({ const err = new HttpClientError({
code: ErrorCode.AuthExpired, code: ErrorCode.AuthExpired,
message: 'session expired', message: 'session expired',
hint: 'run difyctl auth login', hint: 'run difyctl auth login',
@@ -30,7 +30,6 @@ describe('BaseError', () => {
expect(newError(ErrorCode.AuthExpired, 'x').exit()).toBe(ExitCode.Auth) expect(newError(ErrorCode.AuthExpired, 'x').exit()).toBe(ExitCode.Auth)
expect(newError(ErrorCode.UsageInvalidFlag, 'x').exit()).toBe(ExitCode.Usage) expect(newError(ErrorCode.UsageInvalidFlag, 'x').exit()).toBe(ExitCode.Usage)
expect(newError(ErrorCode.VersionSkew, 'x').exit()).toBe(ExitCode.VersionCompat) 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>"', () => { it('toString without hint formats "<code>: <message>"', () => {
@@ -56,7 +55,7 @@ describe('BaseError', () => {
it('withHttpStatus + withRequest + wrap chain immutably', () => { it('withHttpStatus + withRequest + wrap chain immutably', () => {
const cause = new Error('underlying') const cause = new Error('underlying')
const built = newError(ErrorCode.NetworkTimeout, 'timed out') const built = HttpClientError.from(newError(ErrorCode.NetworkConnection, 'timed out'))
.withHttpStatus(504) .withHttpStatus(504)
.withRequest('POST', 'https://x/y') .withRequest('POST', 'https://x/y')
.wrap(cause) .wrap(cause)
@@ -68,7 +67,7 @@ describe('BaseError', () => {
it('wrap exposes cause via standard Error.cause property', () => { it('wrap exposes cause via standard Error.cause property', () => {
const cause = new Error('underlying failure') 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) expect(wrapped.cause).toBe(cause)
}) })
@@ -86,3 +85,56 @@ describe('BaseError', () => {
expect(err.cause).toBe(cause) 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 { ErrorCodeValue, ExitCodeValue } from './codes'
import type { ErrorEnvelope, PrintableError } from './format'
import { ErrorCode, exitFor } from './codes' import { ErrorCode, exitFor } from './codes'
export type BaseErrorOptions = { export type BaseErrorOptions = {
readonly code: ErrorCodeValue readonly code: ErrorCodeValue
readonly message: string readonly message: string
readonly hint?: string readonly hint?: string
readonly httpStatus?: number
readonly method?: string
readonly url?: string
readonly cause?: unknown readonly cause?: unknown
} }
export class BaseError extends Error { export class BaseError extends Error implements PrintableError {
readonly code: ErrorCodeValue readonly code: ErrorCodeValue
readonly hint?: string readonly hint?: string
readonly httpStatus?: number
readonly method?: string
readonly url?: string
constructor(opts: BaseErrorOptions) { constructor(opts: BaseErrorOptions) {
super(opts.message, opts.cause === undefined ? undefined : { cause: opts.cause }) super(opts.message, opts.cause === undefined ? undefined : { cause: opts.cause })
this.name = 'BaseError' this.name = 'BaseError'
this.code = opts.code this.code = opts.code
this.hint = opts.hint this.hint = opts.hint
this.httpStatus = opts.httpStatus
this.method = opts.method
this.url = opts.url
Object.setPrototypeOf(this, new.target.prototype) Object.setPrototypeOf(this, new.target.prototype)
} }
@@ -39,30 +32,31 @@ export class BaseError extends Error {
: `${this.code}: ${this.message}` : `${this.code}: ${this.message}`
} }
withHint(hint: string): BaseError { toEnvelope(): ErrorEnvelope {
return new BaseError({ ...this.snapshot(), hint }) 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 { withHint<T extends BaseError>(this: T, hint: string): T {
return new BaseError({ ...this.snapshot(), httpStatus }) const Ctor = this.constructor as new (opts: BaseErrorOptions) => T
return new Ctor({ ...this.snapshot(), hint })
} }
withRequest(method: string, url: string): BaseError { wrap<T extends BaseError>(this: T, cause: unknown): T {
return new BaseError({ ...this.snapshot(), method, url }) const Ctor = this.constructor as new (opts: BaseErrorOptions) => T
return new Ctor({ ...this.snapshot(), cause })
} }
wrap(cause: unknown): BaseError { protected snapshot(): BaseErrorOptions {
return new BaseError({ ...this.snapshot(), cause })
}
private snapshot(): BaseErrorOptions {
return { return {
code: this.code, code: this.code,
message: this.message, message: this.message,
hint: this.hint, hint: this.hint,
httpStatus: this.httpStatus,
method: this.method,
url: this.url,
cause: this.cause, cause: this.cause,
} }
} }
@@ -76,6 +70,79 @@ export function isBaseError(value: unknown): value is BaseError {
return value instanceof 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 { export function unknownError(message: string, cause?: unknown): BaseError {
return new BaseError({ code: ErrorCode.Unknown, message, cause }) 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.UsageMissingArg, ExitCode.Usage],
[ErrorCode.ConfigInvalidKey, ExitCode.Usage], [ErrorCode.ConfigInvalidKey, ExitCode.Usage],
[ErrorCode.ConfigInvalidValue, ExitCode.Usage], [ErrorCode.ConfigInvalidValue, ExitCode.Usage],
[ErrorCode.NetworkTimeout, ExitCode.Generic],
[ErrorCode.NetworkDns, ExitCode.Generic],
[ErrorCode.Server5xx, ExitCode.Generic], [ErrorCode.Server5xx, ExitCode.Generic],
[ErrorCode.Server4xxOther, ExitCode.Generic], [ErrorCode.Server4xxOther, ExitCode.Generic],
[ErrorCode.ClientError, ExitCode.Generic], [ErrorCode.ClientError, ExitCode.Generic],
+2 -4
View File
@@ -11,8 +11,7 @@ export const ErrorCode = {
UsageMissingArg: 'usage_missing_arg', UsageMissingArg: 'usage_missing_arg',
ConfigInvalidKey: 'config_invalid_key', ConfigInvalidKey: 'config_invalid_key',
ConfigInvalidValue: 'config_invalid_value', ConfigInvalidValue: 'config_invalid_value',
NetworkTimeout: 'network_timeout', NetworkConnection: 'network_connection',
NetworkDns: 'network_dns',
Server5xx: 'server_5xx', Server5xx: 'server_5xx',
Server4xxOther: 'server_4xx_other', Server4xxOther: 'server_4xx_other',
ClientError: 'client_error', ClientError: 'client_error',
@@ -45,8 +44,7 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
usage_missing_arg: ExitCode.Usage, usage_missing_arg: ExitCode.Usage,
config_invalid_key: ExitCode.Usage, config_invalid_key: ExitCode.Usage,
config_invalid_value: ExitCode.Usage, config_invalid_value: ExitCode.Usage,
network_timeout: ExitCode.Generic, network_connection: ExitCode.Generic,
network_dns: ExitCode.Generic,
server_5xx: ExitCode.Generic, server_5xx: ExitCode.Generic,
server_4xx_other: ExitCode.Generic, server_4xx_other: ExitCode.Generic,
client_error: 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 { colorEnabled, colorScheme } from '@/sys/io/color'
import { renderEnvelope } from './envelope'
export type FormatErrorOptions = { export type FormatErrorOptions = {
readonly format?: string readonly format?: string
readonly isErrTTY?: boolean readonly isErrTTY?: boolean
} }
export function formatErrorForCli(err: BaseError, opts: FormatErrorOptions = {}): string { export type ErrorEnvelope = {
if (opts.format === 'json') error: {
return renderEnvelope(err) code: string
return humanError(err, opts.isErrTTY ?? false) 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 cs = colorScheme(colorEnabled(isErrTTY))
const lines: string[] = [`${err.code}: ${err.message}`] const e = env.error
if (err.hint !== undefined) const lines: string[] = [`${e.code}: ${e.message}`]
lines.push(`${cs.magenta('hint:')} ${cs.cyan(err.hint)}`) if (e.hint !== undefined)
if (err.method !== undefined && err.url !== undefined) lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`)
lines.push(`request: ${err.method} ${err.url}`) if (e.method !== undefined && e.url !== undefined)
if (err.httpStatus !== undefined) lines.push(`request: ${e.method} ${e.url}`)
lines.push(`http_status: ${err.httpStatus}`) 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') return lines.join('\n')
} }
+7 -1
View File
@@ -1,6 +1,7 @@
import type { CommandOutput } from './output' import type { CommandOutput } from './output'
import type { ArgDefinition, FlagDefinition, ICommand, InferArgs, InferFlags, OptionalArgValueType } from './types' 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 = { export type CommandConstructor = {
new(): Command new(): Command
@@ -28,11 +29,16 @@ type ParseResult<C extends CommandConstructor> = {
export abstract class Command implements ICommand { export abstract class Command implements ICommand {
static description?: string static description?: string
static flags: Record<string, FlagDefinition<OptionalArgValueType>> = {} static flags: Record<string, FlagDefinition<OptionalArgValueType>> = {}
static args: Record<string, ArgDefinition<string | undefined>> = {} static args: Record<string, ArgDefinition<string | undefined>> = {}
static examples: string[] = [] static examples: string[] = []
abstract run(argv: string[]): Promise<CommandOutput | void> 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> { protected parse<C extends CommandConstructor>(ctor: C, argv: string[]): ParseResult<C> {
const meta = { const meta = {
flags: ctor.flags ?? {}, 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 type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types'
import { UnsupportedArgValueError } from './errors' 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 { function stringFlag<const Opts extends {
description: string description: string
char?: 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> 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 }>( function stringArg<const Opts extends { description: string, required?: boolean }>(
opts: Opts, opts: Opts,
): ArgDefinition<Opts extends { required: true } ? string : string | undefined> { ): 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 { function resolveByChar(char: string, flags: Record<string, FlagDefinition>): [name: string, def: FlagDefinition] | undefined {
for (const [name, def] of Object.entries(meta.flags)) { for (const [name, def] of Object.entries(flags)) {
if (def.char === char) if (def.char === char)
return [name, def] 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 } 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('--')) { if (token.startsWith('--')) {
const eqIdx = token.indexOf('=') const eqIdx = token.indexOf('=')
const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2) const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2)
const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined
const def = meta.flags[name] const def = flags[name]
if (!def) if (!def)
throw new Error(`unknown flag: --${name}`) throw new Error(`unknown flag: --${name}`)
return { name, def, label: `--${name}`, inlineRaw } 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) { if (token.length === 2 && token[1] !== undefined) {
const char = token[1] const char = token[1]
const resolved = resolveByChar(char, meta) const resolved = resolveByChar(char, flags)
if (!resolved) if (!resolved)
throw new Error(`unknown flag: -${char}`) throw new Error(`unknown flag: -${char}`)
const [name, def] = resolved const [name, def] = resolved
@@ -138,6 +148,21 @@ function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
return 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 } { export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } {
const flags: ParsedFlags = {} const flags: ParsedFlags = {}
const positional: string[] = [] const positional: string[] = []
@@ -159,7 +184,10 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P
continue continue
} }
const resolved = resolveToken(token, meta) const resolved = resolveToken(token, {
...meta.flags,
...GLOBAL_FLAGS, // pass global flags to prevent unknown flag error
})
if (!resolved) { if (!resolved) {
positional.push(token) positional.push(token)
continue continue
+4 -4
View File
@@ -1,7 +1,7 @@
import type { CommandConstructor } from './command' import type { CommandConstructor } from './command'
import type { CommandTree } from './registry' import type { CommandTree } from './registry'
import { describe, expect, it } from 'vitest' 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 { ErrorCode, ExitCode } from '@/errors/codes'
import { Command } from './command' import { Command } from './command'
import { run, sniffOutputFormat } from './run' 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 () => { it('routes Server5xx error with http_status line and generic exit', async () => {
class Throwing extends Command { class Throwing extends Command {
async run(_argv: string[]) { 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']) 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 () => { it('renders request line and http_status when both are present', async () => {
class Throwing extends Command { class Throwing extends Command {
async run(_argv: string[]) { 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') .withRequest('GET', 'https://api.dify.ai/v1/me')
.withHttpStatus(502) .withHttpStatus(502)
} }
@@ -197,7 +197,7 @@ describe('run() catch routing', () => {
it('serializes method and url in JSON envelope', async () => { it('serializes method and url in JSON envelope', async () => {
class Throwing extends Command { class Throwing extends Command {
async run(_argv: string[]) { 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') .withRequest('GET', 'https://api.dify.ai/v1/apps/x')
.withHttpStatus(404) .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) if (typeof Ctor.deprecated === 'string' && Ctor.deprecated.length > 0)
process.stderr.write(`deprecated: ${Ctor.deprecated}\n`) process.stderr.write(`deprecated: ${Ctor.deprecated}\n`)
const cmd = new Ctor() 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) if (output !== undefined)
process.stdout.write(stringifyOutput(output)) 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 * as http from 'node:http'
import { startMock } from '@test/fixtures/dify-mock/server' import { startMock } from '@test/fixtures/dify-mock/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 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 { ErrorCode } from '@/errors/codes'
import { openAPIBase } from '@/util/host' import { openAPIBase } from '@/util/host'
import { createHttpClient } from './client.js' import { createHttpClient } from './client.js'
@@ -116,8 +116,8 @@ describe('http client', () => {
await client.get('workspaces') await client.get('workspaces')
} }
catch (err) { caught = err } catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true) expect(isHttpClientError(caught)).toBe(true)
if (isBaseError(caught)) { if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.AuthExpired) expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.httpStatus).toBe(401) expect(caught.httpStatus).toBe(401)
expect(caught.method).toBe('GET') expect(caught.method).toBe('GET')
@@ -138,8 +138,8 @@ describe('http client', () => {
await client.get('workspaces') await client.get('workspaces')
} }
catch (err) { caught = err } catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true) expect(isHttpClientError(caught)).toBe(true)
if (isBaseError(caught)) { if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.Server5xx) expect(caught.code).toBe(ErrorCode.Server5xx)
expect(caught.httpStatus).toBe(503) expect(caught.httpStatus).toBe(503)
} }
@@ -187,8 +187,8 @@ describe('http client', () => {
await client.get('apps/nope/describe') await client.get('apps/nope/describe')
} }
catch (err) { caught = err } catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true) expect(isHttpClientError(caught)).toBe(true)
if (isBaseError(caught)) if (isHttpClientError(caught))
expect(caught.code).toBe(ErrorCode.Server4xxOther) expect(caught.code).toBe(ErrorCode.Server4xxOther)
}) })
@@ -205,8 +205,8 @@ describe('http client', () => {
await client.get('workspaces') await client.get('workspaces')
} }
catch (err) { caught = err } catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true) expect(isHttpClientError(caught)).toBe(true)
if (isBaseError(caught)) if (isHttpClientError(caught))
expect(caught.httpStatus).toBe(429) expect(caught.httpStatus).toBe(429)
}) })
+4 -3
View File
@@ -9,6 +9,7 @@ import type {
RequestOptions, RequestOptions,
ResolvedOptions, ResolvedOptions,
} from './types.js' } from './types.js'
import { isVerbose } from '@/framework/context'
import { userAgent as defaultUserAgent } from '@/version/info' import { userAgent as defaultUserAgent } from '@/version/info'
import { buildBody } from './body.js' import { buildBody } from './body.js'
import { classifyResponse } from './error-mapper.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) await runHooks(state.hooks.onRequest, ctx)
// `dispatcher` is an undici extension to RequestInit, not in @types/node's fetch const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal }
// signature — hence the local type. Carries proxy routing when a proxy env var is set.
const init: RequestInit & { dispatcher?: unknown } = { signal }
if (state.dispatcher !== undefined) if (state.dispatcher !== undefined)
init.dispatcher = state.dispatcher init.dispatcher = state.dispatcher
if (isVerbose())
init.verbose = true
try { try {
ctx.response = await fetch(ctx.request, init) ctx.response = await fetch(ctx.request, init)
+19 -24
View File
@@ -1,5 +1,4 @@
import type { BaseError } from '@/errors/base' import { BaseError, HttpClientError, newError } from '@/errors/base'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes' import { ErrorCode } from '@/errors/codes'
import { redactBearer } from './sanitize' 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> { 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 wire: WireFields = parsed?.error ?? parsed ?? {}
const status = response.status const status = response.status
const url = redactBearer(response.url || request.url) const url = redactBearer(response.url || request.url)
const method = request.method const method = request.method
if (status === 401) { if (status === 401) {
return newError( return HttpClientError.from(newError(
ErrorCode.AuthExpired, ErrorCode.AuthExpired,
wire.message ?? 'session expired or revoked', wire.message ?? 'session expired or revoked',
) ))
.withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again') .withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again')
.withHttpStatus(status) .withHttpStatus(status)
.withRequest(method, url) .withRequest(method, url)
} }
if (status >= 500) { if (status >= 500) {
return newError( return HttpClientError.from(newError(
ErrorCode.Server5xx, ErrorCode.Server5xx,
wire.message ?? `server error (HTTP ${status})`, wire.message ?? `server error (HTTP ${status})`,
) ))
.withHttpStatus(status) .withHttpStatus(status)
.withRequest(method, url) .withRequest(method, url)
.withRawResponse(raw)
} }
const err = newError( const err = HttpClientError.from(newError(
ErrorCode.Server4xxOther, ErrorCode.Server4xxOther,
wire.message ?? `request failed (HTTP ${status})`, wire.message ?? `request failed (HTTP ${status})`,
) ))
.withHttpStatus(status) .withHttpStatus(status)
.withRequest(method, url) .withRequest(method, url)
.withRawResponse(raw)
return wire.hint !== undefined ? err.withHint(wire.hint) : err return wire.hint !== undefined ? err.withHint(wire.hint) : err
} }
export function classifyTransportError(err: unknown): BaseError { export function classifyTransportError(err: unknown): BaseError {
const message = err instanceof Error ? err.message : String(err) if (err instanceof BaseError) {
const sanitized = redactBearer(message) return err
}
if (err instanceof Error && err.name === 'TimeoutError') if (!(err instanceof Error)) {
return newError(ErrorCode.NetworkTimeout, 'request timed out').wrap(err) return newError(ErrorCode.Unknown, String(err)).wrap(err)
if (err instanceof Error && err.name === 'AbortError') }
return newError(ErrorCode.NetworkTimeout, 'request aborted').wrap(err) const sanitized = redactBearer(err.message)
if (sanitized.toLowerCase().includes('econnrefused')) // there isn't a practical way to classify network errors reliably
return newError(ErrorCode.NetworkDns, 'connection refused').wrap(err) return newError(ErrorCode.NetworkConnection, sanitized).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)
} }
+3 -6
View File
@@ -1,7 +1,6 @@
{ {
"extends": "@dify/tsconfig/node.json", "extends": "@dify/tsconfig/node.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "src",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"paths": { "paths": {
"@/*": [ "@/*": [
@@ -12,12 +11,10 @@
] ]
}, },
"types": ["node"], "types": ["node"],
"declaration": true, "noEmit": true, // we already have bundlers to handle this.
"declarationMap": true,
"noEmit": false,
"outDir": "dist", "outDir": "dist",
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts", "test/**/*.ts"], // tests must be included for typechecking
"exclude": ["dist", "test", "node_modules", "**/*.test.ts"] "exclude": ["node_modules", "dist"]
} }