mirror of
https://github.com/langgenius/dify.git
synced 2026-09-19 10:11:30 +08:00
feat(cli): adopt generated oRPC contract for unary endpoints (#37090)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
autofix-ci[bot]
parent
d0b376d31a
commit
a15ecf6bec
@@ -58,6 +58,9 @@
|
||||
"dependencies": {
|
||||
"@dify/contracts": "workspace:*",
|
||||
"@napi-rs/keyring": "catalog:",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/openapi-client": "catalog:",
|
||||
"cli-table3": "catalog:",
|
||||
"eventsource-parser": "catalog:",
|
||||
"js-yaml": "catalog:",
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { SessionListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export class AccountSessionsClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(q?: { page?: number, limit?: number }): Promise<SessionListResponse> {
|
||||
return this.http.get<SessionListResponse>('account/sessions', {
|
||||
searchParams: { page: q?.page, limit: q?.limit },
|
||||
})
|
||||
return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } })
|
||||
}
|
||||
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
await this.http.delete(`account/sessions/${encodeURIComponent(sessionId)}`)
|
||||
await this.orpc.account.sessions.bySessionId.delete({ params: { session_id: sessionId } })
|
||||
}
|
||||
|
||||
async revokeSelf(): Promise<void> {
|
||||
await this.http.delete('account/sessions/self')
|
||||
await this.orpc.account.sessions.self.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { AccountResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export class AccountClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async get(): Promise<AccountResponse> {
|
||||
return this.http.get<AccountResponse>('account')
|
||||
return this.orpc.account.get()
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -1,5 +1,7 @@
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { SseEvent } from '@/http/sse'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { parseSSE } from '@/http/sse'
|
||||
import { normalizeDifyStream } from '@/http/sse-dify'
|
||||
|
||||
@@ -36,9 +38,14 @@ export type StreamOptions = {
|
||||
|
||||
export class AppRunClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
// Mixed class (SPEC §4.4): runStream / reconnectStream are SSE and stay on the raw
|
||||
// `http.stream` facade; stopTask / submitHumanInput are plain JSON and go through the
|
||||
// generated oRPC contract. Both facades share this one transport.
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async runStream(
|
||||
@@ -59,9 +66,8 @@ export class AppRunClient {
|
||||
}
|
||||
|
||||
async stopTask(appId: string, taskId: string): Promise<void> {
|
||||
await this.http.post(`apps/${encodeURIComponent(appId)}/tasks/${encodeURIComponent(taskId)}/stop`, {
|
||||
json: {},
|
||||
timeoutMs: 30_000,
|
||||
await this.orpc.apps.byAppId.tasks.byTaskId.stop.post({
|
||||
params: { app_id: appId, task_id: taskId },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,10 +77,10 @@ export class AppRunClient {
|
||||
action: string,
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.http.post(
|
||||
`apps/${encodeURIComponent(appId)}/form/human_input/${encodeURIComponent(formToken)}`,
|
||||
{ json: { action, inputs }, timeoutMs: 30_000 },
|
||||
)
|
||||
await this.orpc.apps.byAppId.form.humanInput.byFormToken.post({
|
||||
params: { app_id: appId, form_token: formToken },
|
||||
body: { action, inputs },
|
||||
})
|
||||
}
|
||||
|
||||
async reconnectStream(
|
||||
|
||||
+11
-6
@@ -1,5 +1,7 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export type ListQuery = {
|
||||
readonly workspaceId: string
|
||||
@@ -11,15 +13,15 @@ export type ListQuery = {
|
||||
}
|
||||
|
||||
export class AppsClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(q: ListQuery): Promise<AppListResponse> {
|
||||
return this.http.get<AppListResponse>('apps', {
|
||||
searchParams: {
|
||||
return this.orpc.apps.get({
|
||||
query: {
|
||||
workspace_id: q.workspaceId,
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
@@ -31,9 +33,12 @@ export class AppsClient {
|
||||
}
|
||||
|
||||
async describe(appId: string, workspaceId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.http.get<AppDescribeResponse>(`apps/${encodeURIComponent(appId)}/describe`, {
|
||||
searchParams: {
|
||||
return this.orpc.apps.byAppId.describe.get({
|
||||
params: { app_id: appId },
|
||||
query: {
|
||||
workspace_id: workspaceId,
|
||||
// The backend parses a comma-separated string (validator splits on ','); the contract
|
||||
// types `fields` as a string accordingly, so join here rather than send an array.
|
||||
fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
+22
-21
@@ -5,40 +5,41 @@ import type {
|
||||
MemberListResponse,
|
||||
MemberRoleUpdatePayload,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
/**
|
||||
* Thin client for /openapi/v1/workspaces/<id>/members.
|
||||
* Thin client for /openapi/v1/workspaces/<id>/members, over the generated oRPC contract.
|
||||
*
|
||||
* Errors are surfaced as BaseError via classifyResponse on non-2xx
|
||||
* (400/403/404/422). The CLI's AuthedCommand base layer maps those to
|
||||
* user-visible messages — clients never swallow status codes here.
|
||||
* Non-2xx (400/403/404/422) surface as BaseError — the oRPC client maps them at the transport
|
||||
* seam. The CLI's AuthedCommand base layer renders those for the user; clients never swallow codes.
|
||||
*/
|
||||
export class MembersClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise<MemberListResponse> {
|
||||
return this.http.get<MemberListResponse>(
|
||||
`workspaces/${encodeURIComponent(workspaceId)}/members`,
|
||||
{ searchParams: { page: q?.page, limit: q?.limit } },
|
||||
)
|
||||
return this.orpc.workspaces.byWorkspaceId.members.get({
|
||||
params: { workspace_id: workspaceId },
|
||||
query: { page: q?.page, limit: q?.limit },
|
||||
})
|
||||
}
|
||||
|
||||
async invite(workspaceId: string, payload: MemberInvitePayload): Promise<MemberInviteResponse> {
|
||||
return this.http.post<MemberInviteResponse>(
|
||||
`workspaces/${encodeURIComponent(workspaceId)}/members`,
|
||||
{ json: payload },
|
||||
)
|
||||
return this.orpc.workspaces.byWorkspaceId.members.post({
|
||||
params: { workspace_id: workspaceId },
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, memberId: string): Promise<MemberActionResponse> {
|
||||
return this.http.delete<MemberActionResponse>(
|
||||
`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`,
|
||||
)
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.delete({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
})
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
@@ -46,9 +47,9 @@ export class MembersClient {
|
||||
memberId: string,
|
||||
payload: MemberRoleUpdatePayload,
|
||||
): Promise<MemberActionResponse> {
|
||||
return this.http.put<MemberActionResponse>(
|
||||
`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`,
|
||||
{ json: payload },
|
||||
)
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.role.put({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,19 +1,22 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
// Used by every /_version probe call site (the version command and the
|
||||
// per-command auto-nudge). Both must construct their HTTP client with this
|
||||
// timeout + retryAttempts: 0, otherwise the default 30s/3-retry budget kicks in.
|
||||
// The oRPC client below inherits that http instance's policy, so the probe timeout still holds.
|
||||
export const META_PROBE_TIMEOUT_MS = 2000
|
||||
|
||||
export class MetaClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async serverVersion(): Promise<ServerVersionResponse> {
|
||||
return this.http.get<ServerVersionResponse>('_version')
|
||||
return this.orpc.version.get()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
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(), which now
|
||||
// routes through the generated oRPC contract (OpenAPILink over http.request). The happy path
|
||||
// asserts the on-the-wire GET /openapi/v1/workspaces is unchanged; the 401 case asserts the
|
||||
// oRPC client surfaces it as the CLI's classified HttpClientError.
|
||||
|
||||
function makeClient(host: string): WorkspacesClient {
|
||||
return new WorkspacesClient(testHttpClient(host, 'dfoa_test'))
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export class WorkspacesClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
// oRPC client over the same transport (UA+bearer / retry / timeout / error-map) — SPEC §4.4:
|
||||
// one transport, a contract facade. Both methods are standard unary JSON, so both go through
|
||||
// the generated contract.
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(): Promise<WorkspaceListResponse> {
|
||||
return this.http.get<WorkspaceListResponse>('workspaces')
|
||||
return this.orpc.workspaces.get()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,6 +27,6 @@ export class WorkspacesClient {
|
||||
* server's state.
|
||||
*/
|
||||
async switch(workspaceId: string): Promise<WorkspaceDetailResponse> {
|
||||
return this.http.post<WorkspaceDetailResponse>(`workspaces/${encodeURIComponent(workspaceId)}/switch`)
|
||||
return this.orpc.workspaces.byWorkspaceId.switch.post({ params: { workspace_id: workspaceId } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,3 +591,147 @@ describe('hook semantics', () => {
|
||||
await expect(client.get('workspaces')).rejects.toThrow('hook boom on error')
|
||||
})
|
||||
})
|
||||
|
||||
// The low-level request() entrypoint: oRPC's OpenAPILink builds an absolute-URL Request and
|
||||
// calls a fetch-shaped fn. These tests pin the three pitfalls (clone-on-retry / signal merge /
|
||||
// skip-baseURL) plus UA+bearer injection and the raw-Response-on-error contract oRPC relies on.
|
||||
describe('request() entrypoint (oRPC OpenAPILink socket)', () => {
|
||||
it('sends to the Request URL verbatim, skipping the client baseURL', async () => {
|
||||
const mock = await startMock()
|
||||
try {
|
||||
// Deliberately wrong baseURL: if request() re-joined it via joinURL, this would miss.
|
||||
const client = createHttpClient({ baseURL: 'http://wrong.invalid/openapi/v1/', bearer: 'dfoa_test' })
|
||||
const res = await client.request(new Request(`${base(mock.url)}workspaces`))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { workspaces: unknown[] }
|
||||
expect(body.workspaces).toHaveLength(2)
|
||||
}
|
||||
finally {
|
||||
await mock.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('injects UA + bearer that the pre-built Request did not carry', async () => {
|
||||
const mock = await startMock()
|
||||
try {
|
||||
let auth: string | null = null
|
||||
let ua: string | null = null
|
||||
const client = createHttpClient({
|
||||
baseURL: base(mock.url),
|
||||
bearer: 'dfoa_test',
|
||||
userAgent: 'difyctl/req-test',
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
auth = request.headers.get('authorization')
|
||||
ua = request.headers.get('user-agent')
|
||||
},
|
||||
},
|
||||
})
|
||||
await client.request(new Request(`${base(mock.url)}workspaces`))
|
||||
expect(auth).toBe('Bearer dfoa_test')
|
||||
expect(ua).toBe('difyctl/req-test')
|
||||
}
|
||||
finally {
|
||||
await mock.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the raw Response on 4xx and does NOT throw (oRPC reads the body + maps errors)', async () => {
|
||||
const mock = await startMock()
|
||||
try {
|
||||
mock.setScenario('auth-expired')
|
||||
const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' })
|
||||
const res = await client.request(new Request(`${base(mock.url)}workspaces`))
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.status).toBe(401)
|
||||
}
|
||||
finally {
|
||||
await mock.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('clones the Request per attempt so a retried body replays intact', async () => {
|
||||
let hits = 0
|
||||
let secondBody = ''
|
||||
const stub = await startStub((req, res) => {
|
||||
hits++
|
||||
let raw = ''
|
||||
req.on('data', (c) => {
|
||||
raw += String(c)
|
||||
})
|
||||
req.on('end', () => {
|
||||
if (hits === 1) {
|
||||
res.writeHead(503).end()
|
||||
return
|
||||
}
|
||||
secondBody = raw
|
||||
res.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}')
|
||||
})
|
||||
})
|
||||
try {
|
||||
const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', retryAttempts: 2, timeoutMs: 5_000 })
|
||||
// PUT is in RETRY_METHODS and carries a body — proves clone-on-retry replays it.
|
||||
const res = await client.request(new Request(`${stub.url}/x`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ a: 1 }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
expect(res.status).toBe(200)
|
||||
expect(hits).toBe(2)
|
||||
expect(secondBody).toBe('{"a":1}')
|
||||
}
|
||||
finally {
|
||||
await stub.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('applies the client-default timeout on top of the Request signal', async () => {
|
||||
const stub = await startStub(() => { /* never responds */ })
|
||||
try {
|
||||
const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', timeoutMs: 100, retryAttempts: 0 })
|
||||
const ac = new AbortController() // oRPC's own signal, never aborted
|
||||
await expect(client.request(new Request(`${stub.url}/x`, { signal: ac.signal })))
|
||||
.rejects
|
||||
.toBeDefined()
|
||||
}
|
||||
finally {
|
||||
await stub.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry when the Request (oRPC/user) signal aborts', async () => {
|
||||
let hits = 0
|
||||
const stub = await startStub(() => {
|
||||
hits++ // never responds
|
||||
})
|
||||
try {
|
||||
const ac = new AbortController()
|
||||
const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 })
|
||||
const pending = client.request(new Request(`${stub.url}/x`, { method: 'GET', signal: ac.signal }))
|
||||
setTimeout(() => ac.abort(), 50)
|
||||
await expect(pending).rejects.toBeDefined()
|
||||
expect(hits).toBe(1)
|
||||
}
|
||||
finally {
|
||||
await stub.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives each retried attempt a fresh timeout budget', async () => {
|
||||
let hits = 0
|
||||
const stub = await startStub(() => {
|
||||
hits++ // never responds
|
||||
})
|
||||
try {
|
||||
const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', timeoutMs: 100, retryAttempts: 2 })
|
||||
// GET is retryable; each attempt must mint its own 100ms timeout, so all 3 fire. If the
|
||||
// timeout signal were hoisted out of the retry loop, attempt 0's expired signal would
|
||||
// short-circuit attempts 1-2 and hits would be 1.
|
||||
await expect(client.request(new Request(`${stub.url}/x`))).rejects.toBeDefined()
|
||||
expect(hits).toBe(3)
|
||||
}
|
||||
finally {
|
||||
await stub.stop()
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
+122
-61
@@ -82,11 +82,12 @@ async function runHooks(hooks: readonly Hook[], ctx: FetchContext): Promise<void
|
||||
await hook(ctx)
|
||||
}
|
||||
|
||||
function buildSignal(opts: RequestOptions, effectiveTimeoutMs: number | undefined): AbortSignal | undefined {
|
||||
// Merge a fresh per-attempt timeout signal with the persistent user/oRPC signal.
|
||||
// Called once per attempt inside execute() so every retry gets its own timeout budget.
|
||||
function mergeSignal(userSignal: AbortSignal | undefined, effectiveTimeoutMs: number | undefined): AbortSignal | undefined {
|
||||
const timeoutSignal = effectiveTimeoutMs !== undefined && effectiveTimeoutMs > 0
|
||||
? AbortSignal.timeout(effectiveTimeoutMs)
|
||||
: undefined
|
||||
const userSignal = opts.signal
|
||||
|
||||
if (timeoutSignal === undefined)
|
||||
return userSignal
|
||||
@@ -102,7 +103,17 @@ function mergeHeaders(input: HeadersInit | undefined, contentType: string | unde
|
||||
return headers
|
||||
}
|
||||
|
||||
async function dispatch(state: ClientState, path: string, opts: RequestOptions, attempt: number, throwOnErrorDefault: boolean): Promise<Response> {
|
||||
type BuiltRequest = {
|
||||
readonly request: Request
|
||||
readonly resolved: ResolvedOptions
|
||||
readonly effectiveTimeoutMs: number | undefined
|
||||
readonly userSignal: AbortSignal | undefined
|
||||
}
|
||||
|
||||
// Path-keyed constructor: turns (path, opts) into a Request. The Request is built
|
||||
// WITHOUT a signal — execute() supplies a fresh per-attempt signal via fetch's
|
||||
// init.signal (which overrides any signal carried on the Request).
|
||||
function buildRequest(state: ClientState, path: string, opts: RequestOptions, throwOnErrorDefault: boolean): BuiltRequest {
|
||||
const method: HttpMethod = opts.method ?? 'GET'
|
||||
const effectiveTimeoutMs = opts.timeoutMs !== undefined
|
||||
? (opts.timeoutMs > 0 ? opts.timeoutMs : undefined)
|
||||
@@ -114,9 +125,7 @@ async function dispatch(state: ClientState, path: string, opts: RequestOptions,
|
||||
const headers = mergeHeaders(opts.headers, contentType)
|
||||
const url = appendSearchParams(joinURL(state.baseURL, path), opts.searchParams)
|
||||
|
||||
const signal = buildSignal(opts, effectiveTimeoutMs)
|
||||
|
||||
const request = new Request(url, { method, headers, body, signal })
|
||||
const request = new Request(url, { method, headers, body })
|
||||
const resolved: ResolvedOptions = {
|
||||
method,
|
||||
headers,
|
||||
@@ -125,73 +134,100 @@ async function dispatch(state: ClientState, path: string, opts: RequestOptions,
|
||||
retryAttempts: effectiveRetryAttempts,
|
||||
throwOnError,
|
||||
}
|
||||
const ctx: FetchContext = {
|
||||
request,
|
||||
options: resolved,
|
||||
attempt,
|
||||
meta: new Map(),
|
||||
}
|
||||
return { request, resolved, effectiveTimeoutMs, userSignal: opts.signal }
|
||||
}
|
||||
|
||||
await runHooks(state.hooks.onRequest, ctx)
|
||||
// The shared transport engine: hooks -> fetch -> retry -> error-map. Accepts an
|
||||
// already-built Request, so both the path entrypoints (via buildRequest) and the
|
||||
// low-level `request` entrypoint (oRPC's pre-built Request) share one retry/timeout
|
||||
// /error policy.
|
||||
async function execute(
|
||||
state: ClientState,
|
||||
request: Request,
|
||||
resolved: ResolvedOptions,
|
||||
effectiveTimeoutMs: number | undefined,
|
||||
userSignal: AbortSignal | undefined,
|
||||
): Promise<Response> {
|
||||
const { method, retryAttempts: effectiveRetryAttempts, throwOnError } = resolved
|
||||
|
||||
const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal }
|
||||
if (state.dispatcher !== undefined)
|
||||
init.dispatcher = state.dispatcher
|
||||
if (isVerbose())
|
||||
init.verbose = true
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
// Fresh timeout budget per attempt, merged with the persistent user/oRPC signal.
|
||||
// Mirrors the old recursion that re-ran buildSignal() on every retry. Keep this
|
||||
// INSIDE the loop — hoisting it would leak attempt 0's timeout into later attempts.
|
||||
const signal = mergeSignal(userSignal, effectiveTimeoutMs)
|
||||
|
||||
try {
|
||||
ctx.response = await fetch(ctx.request, init)
|
||||
}
|
||||
catch (err) {
|
||||
ctx.error = err
|
||||
// Snapshot the abort cause before onRequestError hooks rewrite ctx.error into BaseError.
|
||||
const userAborted = opts.signal?.aborted === true
|
||||
await runHooks(state.hooks.onRequestError, ctx)
|
||||
// clone-on-retry: the first fetch consumes the Request body, so any attempt that
|
||||
// may still be retried sends a clone and keeps `request` as the pristine replay copy.
|
||||
const sendable = attempt < effectiveRetryAttempts ? request.clone() : request
|
||||
|
||||
// User aborts (ctrl+C) must never retry. Timeouts and other transport errors fall
|
||||
// through to shouldRetry, which enforces the method allowlist.
|
||||
if (!userAborted && attempt < effectiveRetryAttempts && shouldRetry(ctx.error, ctx)) {
|
||||
state.logger?.({ phase: 'retry', method, url: redactBearer(request.url), attempt: attempt + 1 })
|
||||
const delay = backoffDelay(attempt + 1)
|
||||
if (delay > 0)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
return dispatch(state, path, opts, attempt + 1, throwOnErrorDefault)
|
||||
const ctx: FetchContext = {
|
||||
request: sendable,
|
||||
options: resolved,
|
||||
attempt,
|
||||
meta: new Map(),
|
||||
}
|
||||
|
||||
const finalErr = ctx.error
|
||||
if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function')
|
||||
Error.captureStackTrace(finalErr, dispatch)
|
||||
throw finalErr
|
||||
}
|
||||
await runHooks(state.hooks.onRequest, ctx)
|
||||
|
||||
await runHooks(state.hooks.onResponse, ctx)
|
||||
const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal }
|
||||
if (state.dispatcher !== undefined)
|
||||
init.dispatcher = state.dispatcher
|
||||
if (isVerbose())
|
||||
init.verbose = true
|
||||
|
||||
const res = ctx.response
|
||||
if (!res.ok) {
|
||||
if (attempt < effectiveRetryAttempts && shouldRetry(res, ctx)) {
|
||||
state.logger?.({ phase: 'retry', method, url: redactBearer(request.url), attempt: attempt + 1 })
|
||||
// Drain the discarded error body so undici can release the socket back to its
|
||||
// pool instead of holding the connection open until keep-alive timeout / GC.
|
||||
await res.body?.cancel().catch(() => {})
|
||||
const delay = backoffDelay(attempt + 1)
|
||||
if (delay > 0)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
return dispatch(state, path, opts, attempt + 1, throwOnErrorDefault)
|
||||
try {
|
||||
ctx.response = await fetch(ctx.request, init)
|
||||
}
|
||||
catch (err) {
|
||||
ctx.error = err
|
||||
// Snapshot the abort cause before onRequestError hooks rewrite ctx.error into BaseError.
|
||||
const userAborted = userSignal?.aborted === true
|
||||
await runHooks(state.hooks.onRequestError, ctx)
|
||||
|
||||
ctx.error = await classifyResponse(request, res)
|
||||
await runHooks(state.hooks.onResponseError, ctx)
|
||||
// User aborts (ctrl+C) must never retry. Timeouts and other transport errors fall
|
||||
// through to shouldRetry, which enforces the method allowlist.
|
||||
if (!userAborted && attempt < effectiveRetryAttempts && shouldRetry(ctx.error, ctx)) {
|
||||
state.logger?.({ phase: 'retry', method, url: redactBearer(ctx.request.url), attempt: attempt + 1 })
|
||||
const delay = backoffDelay(attempt + 1)
|
||||
if (delay > 0)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
|
||||
if (throwOnError) {
|
||||
const finalErr = ctx.error
|
||||
if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function')
|
||||
Error.captureStackTrace(finalErr, dispatch)
|
||||
Error.captureStackTrace(finalErr, execute)
|
||||
throw finalErr
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
await runHooks(state.hooks.onResponse, ctx)
|
||||
|
||||
const res = ctx.response
|
||||
if (!res.ok) {
|
||||
if (attempt < effectiveRetryAttempts && shouldRetry(res, ctx)) {
|
||||
state.logger?.({ phase: 'retry', method, url: redactBearer(ctx.request.url), attempt: attempt + 1 })
|
||||
// Drain the discarded error body so undici can release the socket back to its
|
||||
// pool instead of holding the connection open until keep-alive timeout / GC.
|
||||
await res.body?.cancel().catch(() => {})
|
||||
const delay = backoffDelay(attempt + 1)
|
||||
if (delay > 0)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
|
||||
ctx.error = await classifyResponse(ctx.request, res)
|
||||
await runHooks(state.hooks.onResponseError, ctx)
|
||||
|
||||
if (throwOnError) {
|
||||
const finalErr = ctx.error
|
||||
if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function')
|
||||
Error.captureStackTrace(finalErr, execute)
|
||||
throw finalErr
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
// 204/205 and empty 2xx bodies carry no JSON. Resolve to `undefined` instead of
|
||||
@@ -209,17 +245,19 @@ export function createHttpClient(opts: ClientOptions): HttpClient {
|
||||
|
||||
const typedCall = async <T>(method: HttpMethod, path: string, callOpts?: RequestOptions): Promise<T> => {
|
||||
const finalOpts: RequestOptions = { ...callOpts, method }
|
||||
const res = await dispatch(state, path, finalOpts, 0, true)
|
||||
const built = buildRequest(state, path, finalOpts, true)
|
||||
const res = await execute(state, built.request, built.resolved, built.effectiveTimeoutMs, built.userSignal)
|
||||
return parseJsonBody<T>(res)
|
||||
}
|
||||
|
||||
const rawFetch = (path: string, callOpts?: RequestOptions): Promise<Response> => {
|
||||
const finalOpts: RequestOptions = { ...callOpts, method: callOpts?.method ?? 'GET' }
|
||||
return dispatch(state, path, finalOpts, 0, false)
|
||||
const built = buildRequest(state, path, finalOpts, false)
|
||||
return execute(state, built.request, built.resolved, built.effectiveTimeoutMs, built.userSignal)
|
||||
}
|
||||
|
||||
const streamFetch = (path: string, callOpts?: RequestOptions): Promise<Response> => {
|
||||
// SSE bodies must not be aborted by a request-level timeout — `0` is the dispatch
|
||||
// SSE bodies must not be aborted by a request-level timeout — `0` is the buildRequest
|
||||
// sentinel for "no timeout" and also overrides the client default.
|
||||
const finalOpts: RequestOptions = {
|
||||
...callOpts,
|
||||
@@ -227,12 +265,34 @@ export function createHttpClient(opts: ClientOptions): HttpClient {
|
||||
retryAttempts: 0,
|
||||
timeoutMs: 0,
|
||||
}
|
||||
return dispatch(state, path, finalOpts, 0, false)
|
||||
const built = buildRequest(state, path, finalOpts, false)
|
||||
return execute(state, built.request, built.resolved, built.effectiveTimeoutMs, built.userSignal)
|
||||
}
|
||||
|
||||
// Low-level entrypoint for oRPC's OpenAPILink: executes an already-built, absolute-URL
|
||||
// Request through the same transport (UA+bearer hooks, retry, timeout, error-map) while
|
||||
// skipping joinURL. Policy comes from the client instance defaults — there is no per-call
|
||||
// override, so this stays a drop-in for OpenAPILink's `(req, init) => Promise<Response>`.
|
||||
// Returns the raw Response for every status; the oRPC fetch wrapper (orpc.ts) inspects the
|
||||
// status and raises classifyResponse for non-2xx, so error mapping stays in one place.
|
||||
const requestFetch = (req: Request, init?: RequestInit): Promise<Response> => {
|
||||
const method = req.method.toUpperCase() as HttpMethod
|
||||
const resolved: ResolvedOptions = {
|
||||
method,
|
||||
headers: req.headers,
|
||||
body: undefined,
|
||||
timeoutMs: state.defaultTimeoutMs,
|
||||
retryAttempts: state.defaultRetryAttempts,
|
||||
throwOnError: false,
|
||||
}
|
||||
const userSignal = init?.signal ?? req.signal
|
||||
return execute(state, req, resolved, state.defaultTimeoutMs, userSignal)
|
||||
}
|
||||
|
||||
const extend = (overrides: Partial<ClientOptions>): HttpClient => createHttpClient({ ...state.originalOptions, ...overrides })
|
||||
|
||||
return {
|
||||
baseURL: state.baseURL,
|
||||
get: <T>(p: string, o?: RequestOptions) => typedCall<T>('GET', p, o),
|
||||
post: <T>(p: string, o?: RequestOptions) => typedCall<T>('POST', p, o),
|
||||
put: <T>(p: string, o?: RequestOptions) => typedCall<T>('PUT', p, o),
|
||||
@@ -240,6 +300,7 @@ export function createHttpClient(opts: ClientOptions): HttpClient {
|
||||
delete: <T>(p: string, o?: RequestOptions) => typedCall<T>('DELETE', p, o),
|
||||
fetch: rawFetch,
|
||||
stream: streamFetch,
|
||||
request: requestFetch,
|
||||
extend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { openAPIBase } from '@/util/host'
|
||||
import { createHttpClient } from './client.js'
|
||||
import { createOpenApiClient } from './orpc.js'
|
||||
|
||||
// createOpenApiClient maps errors at the transport seams, so a migrated endpoint surfaces the
|
||||
// SAME classified HttpClientError as the `this.http.*` path — Dify's message/hint, the request
|
||||
// method/url, and the raw body — straight from a plain `orpc.x()` call, with no per-call wrapper.
|
||||
function orpcClient(host: string) {
|
||||
// retryAttempts: 0 so the 5xx case fails fast instead of burning the backoff budget.
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), bearer: 'dfoa_test', retryAttempts: 0 })
|
||||
return createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async function catchErr(run: () => Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await run()
|
||||
return undefined
|
||||
}
|
||||
catch (err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
describe('createOpenApiClient error mapping', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('recovers Dify message + hint from a top-level 4xx envelope', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(403, { message: 'no access', hint: 'ask an admin' }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.Server4xxOther)
|
||||
expect(caught.httpStatus).toBe(403)
|
||||
expect(caught.message).toBe('no access')
|
||||
expect(caught.hint).toBe('ask an admin')
|
||||
// Parity with the transport path: the migrated endpoint's error keeps the request
|
||||
// method/url and the raw body, so formatted errors still print the `request:` line
|
||||
// and the raw-response dump (not just message/hint).
|
||||
expect(caught.method).toBe('GET')
|
||||
expect(caught.url).toContain('/account')
|
||||
expect(caught.rawResponse).toContain('no access')
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers from a nested { error: { message, hint } } envelope and keeps the auth code on 401', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(401, { error: { message: 'expired', hint: 'relogin' } }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.AuthExpired)
|
||||
expect(caught.httpStatus).toBe(401)
|
||||
expect(caught.message).toBe('expired')
|
||||
expect(caught.hint).toBe('relogin')
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the default auth-login hint when the body carries none', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.AuthExpired)
|
||||
expect(caught.hint).toContain('difyctl auth login')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps 5xx to Server5xx', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(503, { message: 'down for maintenance' }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.Server5xx)
|
||||
expect(caught.httpStatus).toBe(503)
|
||||
expect(caught.message).toBe('down for maintenance')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
import type { HttpClient } from './types.js'
|
||||
import { contract } from '@dify/contracts/api/openapi/orpc.gen'
|
||||
import { createORPCClient } from '@orpc/client'
|
||||
import { OpenAPILink } from '@orpc/openapi-client/fetch'
|
||||
import { isBaseError, unknownError } from '@/errors/base'
|
||||
import { classifyResponse } from './error-mapper.js'
|
||||
|
||||
// Contract-typed oRPC client for the public OpenAPI surface. `JsonifiedClient` reshapes the
|
||||
// contract types to what survives JSON transport (e.g. Date -> string), matching the wire.
|
||||
export type OpenApiClient = JsonifiedClient<ContractRouterClient<typeof contract>>
|
||||
|
||||
// An oRPC client routed through the CLI's HttpClient, so every call reuses the one transport
|
||||
// policy (UA+bearer, retry, timeout). Errors become the CLI's model at the two transport seams,
|
||||
// so call sites stay plain `this.orpc.x.y(input)` with no per-method try/catch:
|
||||
// - fetch wrapper: non-2xx -> classifyResponse (identical to the `this.http.*` path), leaving
|
||||
// oRPC to decode only 2xx responses;
|
||||
// - link wrapper: the one residual throw (a 2xx body oRPC can't decode) -> mapOrpcError.
|
||||
export function createOpenApiClient(http: HttpClient): OpenApiClient {
|
||||
const link = new OpenAPILink(contract, {
|
||||
url: http.baseURL,
|
||||
fetch: async (req, init) => {
|
||||
const res = await http.request(req, init)
|
||||
if (!res.ok)
|
||||
throw await classifyResponse(req, res)
|
||||
return res
|
||||
},
|
||||
})
|
||||
return createORPCClient<OpenApiClient>({
|
||||
call: (path, input, options) => link.call(path, input, options).catch(mapOrpcError),
|
||||
})
|
||||
}
|
||||
|
||||
// Non-2xx and transport failures already arrive as BaseError (from the fetch wrapper / transport)
|
||||
// and re-throw unchanged; the only residual is a 2xx body oRPC failed to decode.
|
||||
function mapOrpcError(err: unknown): never {
|
||||
if (isBaseError(err))
|
||||
throw err
|
||||
throw unknownError(err instanceof Error ? err.message : String(err), err)
|
||||
}
|
||||
@@ -73,6 +73,9 @@ export type ClientOptions = {
|
||||
}
|
||||
|
||||
export type HttpClient = {
|
||||
// The resolved base URL this client was created with (e.g. openAPIBase(host)). Exposed so
|
||||
// callers can build a contract/oRPC facade over the same transport without re-deriving it.
|
||||
readonly baseURL: string
|
||||
readonly get: <T>(path: string, opts?: RequestOptions) => Promise<T>
|
||||
readonly post: <T>(path: string, opts?: RequestOptions) => Promise<T>
|
||||
readonly put: <T>(path: string, opts?: RequestOptions) => Promise<T>
|
||||
@@ -80,5 +83,8 @@ export type HttpClient = {
|
||||
readonly delete: <T>(path: string, opts?: RequestOptions) => Promise<T>
|
||||
readonly fetch: (path: string, opts?: RequestOptions) => Promise<Response>
|
||||
readonly stream: (path: string, opts?: RequestOptions) => Promise<Response>
|
||||
// Low-level entrypoint for oRPC's OpenAPILink: runs a pre-built, absolute-URL Request
|
||||
// through the transport (UA+bearer, retry, timeout, error-map) without re-joining baseURL.
|
||||
readonly request: (req: Request, init?: RequestInit) => Promise<Response>
|
||||
readonly extend: (overrides: Partial<ClientOptions>) => HttpClient
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user