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:
L1nSn0w
2026-06-08 08:09:44 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 autofix-ci[bot]
parent d0b376d31a
commit a15ecf6bec
27 changed files with 798 additions and 184 deletions
+3
View File
@@ -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:",
+7 -7
View File
@@ -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()
}
}
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
}
+4 -1
View File
@@ -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'))
+9 -4
View File
@@ -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 } })
}
}
+144
View File
@@ -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
View File
@@ -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,
}
}
+98
View File
@@ -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')
}
})
})
+41
View File
@@ -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)
}
+6
View File
@@ -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
}