Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions spx-gui/src/apis/common/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiException, ApiExceptionCode, isQuotaExceededMeta } from './exception'
import { Client } from './client'

function makeMovedResponse(canonicalPath: string) {
return new Response(
JSON.stringify({
code: ApiExceptionCode.errorResourceMoved,
msg: 'Resource moved',
canonical: {
path: canonicalPath
}
}),
{
status: 409,
headers: {
'Content-Type': 'application/json'
}
}
)
}

function makeQuotaExceededResponse(retryAfter: string) {
return new Response(
JSON.stringify({
code: ApiExceptionCode.errorQuotaExceeded,
msg: 'Quota exceeded'
}),
{
status: 403,
headers: {
'Content-Type': 'application/json',
'Retry-After': retryAfter
}
}
)
}

describe('Client', () => {
let client: Client
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>

beforeEach(() => {
fetchMock = vi.fn<typeof fetch>()
client = new Client({
baseUrl: 'https://api.example.com',
fetchFn: fetchMock
})
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('path-based moved conflicts', () => {
it('should surface the moved conflict without retrying', async () => {
fetchMock.mockResolvedValueOnce(makeMovedResponse('/project/john/demo/view'))

try {
await client.post('/project/John/demo/view')
throw new Error('expected moved conflict')
} catch (e) {
expect(e).toBeInstanceOf(ApiException)
expect(e).toMatchObject({
code: ApiExceptionCode.errorResourceMoved,
meta: {
path: '/project/john/demo/view'
}
})
}

expect(fetchMock).toHaveBeenCalledTimes(1)
expect(new URL((fetchMock.mock.calls[0]![0] as Request).url).pathname).toBe('/project/John/demo/view')
})
})

describe('quota exceeded metadata', () => {
it('should parse retry-after metadata from headers', async () => {
const retryAfter = 'Wed, 09 Apr 2026 08:00:00 GMT'
fetchMock.mockResolvedValueOnce(makeQuotaExceededResponse(retryAfter))

try {
await client.get('/quota')
throw new Error('expected quota exceeded error')
} catch (e) {
expect(e).toBeInstanceOf(ApiException)
expect(e).toMatchObject({
code: ApiExceptionCode.errorQuotaExceeded
})
expect(isQuotaExceededMeta((e as ApiException).code, (e as ApiException).meta)).toBe(true)
expect((e as ApiException).meta).toMatchObject({
retryAfter: new Date(retryAfter).valueOf()
})
}
})
})

describe('default fetch binding', () => {
it('should call the global fetch with the global receiver when fetchFn is not injected', async () => {
const globalFetchMock = vi.fn(function (this: typeof globalThis, _req: RequestInfo | URL, _init?: RequestInit) {
expect(this).toBe(globalThis)
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }))
})
vi.stubGlobal('fetch', globalFetchMock)

const defaultClient = new Client({
baseUrl: 'https://api.example.com'
})

await defaultClient.get('/health')

expect(globalFetchMock).toHaveBeenCalledTimes(1)
})
})
})
47 changes: 43 additions & 4 deletions spx-gui/src/apis/common/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
*/

import * as Sentry from '@sentry/vue'
import dayjs from 'dayjs'
import { apiBaseUrl } from '@/utils/env'
import { TimeoutException } from '@/utils/exception/base'
import { mergeSignals } from '@/utils/disposable'
import { ApiException } from './exception'
import { ApiException, ApiExceptionCode, type MovedResourceCanonical, type QuotaExceededMeta } from './exception'
import { parseSSE, type SSEEvent } from './sse'

/** Response body when exception encountered for API calling */
Expand All @@ -15,12 +16,36 @@ export type ApiExceptionPayload = {
code: number
/** Message for developer reading */
msg: string
canonical?: MovedResourceCanonical
}

function isApiExceptionPayload(body: any): body is ApiExceptionPayload {
return body && typeof body.code === 'number' && typeof body.msg === 'string'
}

function getQuotaExceededMeta(headers: Headers): QuotaExceededMeta {
const retryAfter = headers.get('Retry-After')
let date
if (retryAfter != null) {
const seconds = Number(retryAfter)
date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter)
}
return {
retryAfter: date?.isValid() ? date.valueOf() : null
}
}

function getApiExceptionMeta(code: number, resp: Response, payload: ApiExceptionPayload): unknown {
switch (code) {
case ApiExceptionCode.errorQuotaExceeded:
return getQuotaExceededMeta(resp.headers)
case ApiExceptionCode.errorResourceMoved:
return payload.canonical ?? null
default:
return null
}
}

/** TokenProvider provides access token used for the Authorization header */
export type TokenProvider = () => Promise<string | null>

Expand All @@ -41,13 +66,24 @@ export type JSONSSEEvent = {
data: unknown
}

export type ClientOptions = {
baseUrl?: string
fetchFn?: typeof fetch
}

export class Client {
constructor(options: ClientOptions = {}) {
this.baseUrl = options.baseUrl ?? apiBaseUrl
this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis)
}

private tokenProvider: TokenProvider = async () => null
setTokenProvider(provider: TokenProvider) {
this.tokenProvider = provider
}

private baseUrl = apiBaseUrl
private baseUrl: string
private fetchFn: typeof fetch
private defaultTimeout = 10 * 1000 // 10 seconds

/** Prepare request object, stringifying payload as JSON */
Expand All @@ -74,7 +110,7 @@ export class Client {
const timeoutCtrl = new AbortController()
const timeoutTimer = setTimeout(() => timeoutCtrl.abort(new TimeoutException()), timeout)
const signal = mergeSignals(options?.signal, timeoutCtrl.signal)
const resp = await fetch(req, { signal }).finally(() => clearTimeout(timeoutTimer))
const resp = await this.fetchFn(req, { signal }).finally(() => clearTimeout(timeoutTimer))
Comment thread
nighca marked this conversation as resolved.
if (!resp.ok) {
let payload: ApiExceptionPayload | undefined
try {
Expand All @@ -84,7 +120,10 @@ export class Client {
// ignore
}
if (payload == null) throw new Error(`status ${resp.status} for api call: ${req.url.slice(0, 200)}`)
throw new ApiException(payload.code, payload.msg, { req, resp })
throw new ApiException(payload.code, payload.msg, {
req,
meta: getApiExceptionMeta(payload.code, resp, payload)
})
}
return resp
}
Expand Down
40 changes: 20 additions & 20 deletions spx-gui/src/apis/common/exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import type { LocaleMessage } from '@/utils/i18n'
import { Exception } from '@/utils/exception'
import dayjs from 'dayjs'

export class ApiException extends Exception {
name = 'ApiException'
Expand All @@ -14,11 +13,11 @@ export class ApiException extends Exception {
constructor(
public code: number,
message: string,
{ req, resp }: { req: Request; resp: Response }
{ req, meta }: { req: Request; meta?: unknown }
) {
super(`[${code}] ${message} (${req.method} ${req.url.slice(0, 200)})`)
this.userMessage = codeMessages[this.code as ApiExceptionCode] ?? null
this.meta = codeMetas[this.code as ApiExceptionCode]?.(resp.headers) ?? null
this.meta = meta ?? null
}
}

Expand All @@ -28,29 +27,26 @@ export enum ApiExceptionCode {
errorForbidden = 40300,
errorQuotaExceeded = 40301,
errorNotFound = 40400,
errorResourceMoved = 40901,
errorTooManyRequests = 42900,
errorRateLimitExceeded = 42901,
errorScratchFeatureNotSupported = 50101,
errorUnknown = 50000
}

export type MovedResourceCanonical = {
path: string
username?: string
owner?: string
name?: string
release?: string
}

export type QuotaExceededMeta = {
// milliseconds or null
retryAfter: number | null
}
const codeMetas: { [key in ApiExceptionCode]?: (headers: Headers) => unknown } = {
[ApiExceptionCode.errorQuotaExceeded]: (headers): QuotaExceededMeta => {
const retryAfter = headers.get('Retry-After')
let date
if (retryAfter != null) {
const seconds = Number(retryAfter)
date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter)
}
return {
retryAfter: date?.isValid() ? date.valueOf() : null
}
}
}

export function isQuotaExceededMeta(code: number, meta: unknown): meta is QuotaExceededMeta {
return code === ApiExceptionCode.errorQuotaExceeded && meta != null
}
Expand All @@ -72,6 +68,14 @@ const codeMessages: Record<ApiExceptionCode, LocaleMessage> = {
en: 'quota exceeded',
zh: '超出配额'
},
[ApiExceptionCode.errorNotFound]: {
en: 'resource not exist',
zh: '资源不存在'
},
[ApiExceptionCode.errorResourceMoved]: {
en: 'resource moved',
zh: '资源已迁移'
},
[ApiExceptionCode.errorTooManyRequests]: {
en: 'too many requests',
zh: '请求太频繁了'
Expand All @@ -80,10 +84,6 @@ const codeMessages: Record<ApiExceptionCode, LocaleMessage> = {
en: 'rate limit exceeded, please retry later',
zh: '触发频率限制,请稍后重试'
},
[ApiExceptionCode.errorNotFound]: {
en: 'resource not exist',
zh: '资源不存在'
},
[ApiExceptionCode.errorScratchFeatureNotSupported]: {
en: 'Some Scratch features are not supported yet',
zh: '部分 Scratch 特性暂不支持'
Expand Down
Loading