mirror of
https://github.com/langgenius/dify.git
synced 2026-06-03 08:16:37 +08:00
refactor: improve network error and allow verbose output (#36923)
This commit is contained in:
+5
-1
@@ -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,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,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,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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 ?? {},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user