fix(canary-phase14): single-source envelope parsing + query-key splay
Address audit findings S-1, S-3, S-4, S-7 pre-rollup (fix-in-phase).
S-1: Eliminate hand-rolled envelope error parser duplication.
Moved transformAxiosError out of core/api/errors.ts (where it
required a private parseEnvelopeError mirror of the Zod schema)
into api/client.ts where it now uses apiErrorEnvelopeSchema directly
via safeParse. core/api/errors.ts is now ApiError class + code
constants + module augmentation only, with no canary-envelope
knowledge — keeps layering core <- universal, api <- canary clean.
S-3: useManageToken query key now splays cursor/limit individually
instead of carrying the params object reference. Without this,
callers passing a freshly-allocated { cursor } each render would
miss the cache even with identical values.
S-4: unwrapEnvelope's PARSE_ERROR now includes the first Zod issue
(path + message) in the ApiError.message so operators see WHICH
field shape failed, not just that something did.
S-7: Add request interceptor rejection handler so request-phase
errors propagate via Promise.reject instead of throwing
synchronously.
This commit is contained in:
parent
f6194d87b9
commit
7c0a504d0b
|
|
@ -3,14 +3,29 @@
|
||||||
// client.ts
|
// client.ts
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
|
import axios, {
|
||||||
|
type AxiosError,
|
||||||
|
type AxiosInstance,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from 'axios'
|
||||||
import type { ZodType } from 'zod'
|
import type { ZodType } from 'zod'
|
||||||
import { ApiError, ApiErrorCode, transformAxiosError } from '@/core/api'
|
import { ApiError, ApiErrorCode } from '@/core/api'
|
||||||
import { successEnvelope } from './types/error'
|
import { apiErrorEnvelopeSchema, successEnvelope } from './types/error'
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
const TURNSTILE_HEADER_NAME = 'CF-Turnstile-Response'
|
const TURNSTILE_HEADER_NAME = 'CF-Turnstile-Response'
|
||||||
|
|
||||||
|
const STATUS_FALLBACK_CODE: Readonly<Record<number, ApiErrorCode>> = {
|
||||||
|
400: ApiErrorCode.VALIDATION_ERROR,
|
||||||
|
404: ApiErrorCode.NOT_FOUND,
|
||||||
|
410: ApiErrorCode.NOT_FOUND,
|
||||||
|
429: ApiErrorCode.RATE_LIMITED,
|
||||||
|
500: ApiErrorCode.INTERNAL_ERROR,
|
||||||
|
502: ApiErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
503: ApiErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
504: ApiErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
}
|
||||||
|
|
||||||
const resolveBaseURL = (): string => {
|
const resolveBaseURL = (): string => {
|
||||||
const fromEnv = import.meta.env.VITE_API_URL
|
const fromEnv = import.meta.env.VITE_API_URL
|
||||||
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
|
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
|
||||||
|
|
@ -35,6 +50,26 @@ export function setTurnstileTokenProvider(
|
||||||
turnstileProvider = provider
|
turnstileProvider = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transformAxiosError(error: AxiosError<unknown>): ApiError {
|
||||||
|
if (!error.response) {
|
||||||
|
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
|
||||||
|
}
|
||||||
|
const { status, data } = error.response
|
||||||
|
|
||||||
|
const parsed = apiErrorEnvelopeSchema.safeParse(data)
|
||||||
|
if (parsed.success) {
|
||||||
|
return new ApiError(
|
||||||
|
parsed.data.error.message,
|
||||||
|
parsed.data.error.code,
|
||||||
|
status,
|
||||||
|
parsed.data.error.fields
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = STATUS_FALLBACK_CODE[status] ?? ApiErrorCode.UNKNOWN_ERROR
|
||||||
|
return new ApiError('Request failed', fallback, status)
|
||||||
|
}
|
||||||
|
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
||||||
const token = turnstileProvider?.()
|
const token = turnstileProvider?.()
|
||||||
|
|
@ -42,7 +77,8 @@ apiClient.interceptors.request.use(
|
||||||
config.headers.set(TURNSTILE_HEADER_NAME, token)
|
config.headers.set(TURNSTILE_HEADER_NAME, token)
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
},
|
||||||
|
(error: unknown) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
|
|
@ -55,11 +91,25 @@ apiClient.interceptors.response.use(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ParseIssue = {
|
||||||
|
path: readonly PropertyKey[]
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeParseFailure(issues: readonly ParseIssue[]): string {
|
||||||
|
const first = issues[0]
|
||||||
|
if (!first) {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
const path = first.path.length > 0 ? first.path.join('.') : '<root>'
|
||||||
|
return `${path}: ${first.message}`
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapEnvelope<T>(data: unknown, schema: ZodType<T>, status: number): T {
|
function unwrapEnvelope<T>(data: unknown, schema: ZodType<T>, status: number): T {
|
||||||
const parsed = successEnvelope(schema).safeParse(data)
|
const parsed = successEnvelope(schema).safeParse(data)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
'response shape mismatch',
|
`response shape mismatch (${describeParseFailure(parsed.error.issues)})`,
|
||||||
ApiErrorCode.PARSE_ERROR,
|
ApiErrorCode.PARSE_ERROR,
|
||||||
status
|
status
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,12 @@ export function useManageToken(manageId: string, params?: UseManageTokenParams)
|
||||||
const search = buildSearch(params)
|
const search = buildSearch(params)
|
||||||
const path = `${MANAGE_PATH_PREFIX}${encodeURIComponent(manageId)}${search}`
|
const path = `${MANAGE_PATH_PREFIX}${encodeURIComponent(manageId)}${search}`
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...MANAGE_KEY_PREFIX, manageId, params ?? null],
|
queryKey: [
|
||||||
|
...MANAGE_KEY_PREFIX,
|
||||||
|
manageId,
|
||||||
|
params?.cursor ?? null,
|
||||||
|
params?.limit ?? null,
|
||||||
|
],
|
||||||
queryFn: () => apiGet(path, manageResponseSchema),
|
queryFn: () => apiGet(path, manageResponseSchema),
|
||||||
enabled: manageId.length > 0,
|
enabled: manageId.length > 0,
|
||||||
...QUERY_STRATEGIES.frequent,
|
...QUERY_STRATEGIES.frequent,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
// errors.ts
|
// errors.ts
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import type { AxiosError } from 'axios'
|
|
||||||
|
|
||||||
export const ApiErrorCode = {
|
export const ApiErrorCode = {
|
||||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||||
PARSE_ERROR: 'PARSE_ERROR',
|
PARSE_ERROR: 'PARSE_ERROR',
|
||||||
|
|
@ -24,17 +22,6 @@ export const ApiErrorCode = {
|
||||||
|
|
||||||
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
|
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
|
||||||
|
|
||||||
const STATUS_FALLBACK_CODE: Record<number, ApiErrorCode> = {
|
|
||||||
400: ApiErrorCode.VALIDATION_ERROR,
|
|
||||||
404: ApiErrorCode.NOT_FOUND,
|
|
||||||
410: ApiErrorCode.NOT_FOUND,
|
|
||||||
429: ApiErrorCode.RATE_LIMITED,
|
|
||||||
500: ApiErrorCode.INTERNAL_ERROR,
|
|
||||||
502: ApiErrorCode.SERVICE_UNAVAILABLE,
|
|
||||||
503: ApiErrorCode.SERVICE_UNAVAILABLE,
|
|
||||||
504: ApiErrorCode.SERVICE_UNAVAILABLE,
|
|
||||||
}
|
|
||||||
|
|
||||||
const USER_FACING_COPY: Partial<Record<string, string>> = {
|
const USER_FACING_COPY: Partial<Record<string, string>> = {
|
||||||
[ApiErrorCode.NETWORK_ERROR]:
|
[ApiErrorCode.NETWORK_ERROR]:
|
||||||
'Unable to reach the server. Check your connection.',
|
'Unable to reach the server. Check your connection.',
|
||||||
|
|
@ -71,64 +58,6 @@ export class ApiError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnvelopeErrorShape {
|
|
||||||
success?: unknown
|
|
||||||
error?: {
|
|
||||||
code?: unknown
|
|
||||||
message?: unknown
|
|
||||||
fields?: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvelopeError(
|
|
||||||
data: unknown
|
|
||||||
): { code: string; message: string; fields?: Record<string, string> } | null {
|
|
||||||
if (data === null || typeof data !== 'object') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const envelope = data as EnvelopeErrorShape
|
|
||||||
if (envelope.success !== false || envelope.error == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const { code, message, fields } = envelope.error
|
|
||||||
if (typeof code !== 'string' || typeof message !== 'string') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
message,
|
|
||||||
fields: parseFields(fields),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFields(raw: unknown): Record<string, string> | undefined {
|
|
||||||
if (raw === null || typeof raw !== 'object') {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const out: Record<string, string> = {}
|
|
||||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.keys(out).length > 0 ? out : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
|
|
||||||
if (!error.response) {
|
|
||||||
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
|
|
||||||
}
|
|
||||||
const { status, data } = error.response
|
|
||||||
|
|
||||||
const envelope = parseEnvelopeError(data)
|
|
||||||
if (envelope) {
|
|
||||||
return new ApiError(envelope.message, envelope.code, status, envelope.fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackCode = STATUS_FALLBACK_CODE[status] ?? ApiErrorCode.UNKNOWN_ERROR
|
|
||||||
return new ApiError('Request failed', fallbackCode, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@tanstack/react-query' {
|
declare module '@tanstack/react-query' {
|
||||||
interface Register {
|
interface Register {
|
||||||
defaultError: ApiError
|
defaultError: ApiError
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue