From 7c0a504d0b754ea9eedac6ce0a5122df7718dd9d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 17 May 2026 13:03:27 -0400 Subject: [PATCH] fix(canary-phase14): single-source envelope parsing + query-key splay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../frontend/src/api/client.ts | 60 ++++++++++++++-- .../frontend/src/api/hooks/useManageToken.ts | 7 +- .../frontend/src/core/api/errors.ts | 71 ------------------- 3 files changed, 61 insertions(+), 77 deletions(-) diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/client.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/client.ts index 1f00d19f..1ef24a24 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/api/client.ts +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/client.ts @@ -3,14 +3,29 @@ // 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 { ApiError, ApiErrorCode, transformAxiosError } from '@/core/api' -import { successEnvelope } from './types/error' +import { ApiError, ApiErrorCode } from '@/core/api' +import { apiErrorEnvelopeSchema, successEnvelope } from './types/error' const REQUEST_TIMEOUT_MS = 15000 const TURNSTILE_HEADER_NAME = 'CF-Turnstile-Response' +const STATUS_FALLBACK_CODE: Readonly> = { + 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 fromEnv = import.meta.env.VITE_API_URL if (typeof fromEnv === 'string' && fromEnv.length > 0) { @@ -35,6 +50,26 @@ export function setTurnstileTokenProvider( turnstileProvider = provider } +function transformAxiosError(error: AxiosError): 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( (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { const token = turnstileProvider?.() @@ -42,7 +77,8 @@ apiClient.interceptors.request.use( config.headers.set(TURNSTILE_HEADER_NAME, token) } return config - } + }, + (error: unknown) => Promise.reject(error) ) 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('.') : '' + return `${path}: ${first.message}` +} + function unwrapEnvelope(data: unknown, schema: ZodType, status: number): T { const parsed = successEnvelope(schema).safeParse(data) if (!parsed.success) { throw new ApiError( - 'response shape mismatch', + `response shape mismatch (${describeParseFailure(parsed.error.issues)})`, ApiErrorCode.PARSE_ERROR, status ) diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useManageToken.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useManageToken.ts index 559a7634..a7a275f0 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useManageToken.ts +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useManageToken.ts @@ -35,7 +35,12 @@ export function useManageToken(manageId: string, params?: UseManageTokenParams) const search = buildSearch(params) const path = `${MANAGE_PATH_PREFIX}${encodeURIComponent(manageId)}${search}` return useQuery({ - queryKey: [...MANAGE_KEY_PREFIX, manageId, params ?? null], + queryKey: [ + ...MANAGE_KEY_PREFIX, + manageId, + params?.cursor ?? null, + params?.limit ?? null, + ], queryFn: () => apiGet(path, manageResponseSchema), enabled: manageId.length > 0, ...QUERY_STRATEGIES.frequent, diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts index 809f8eeb..85a145b8 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts @@ -3,8 +3,6 @@ // errors.ts // =================== -import type { AxiosError } from 'axios' - export const ApiErrorCode = { NETWORK_ERROR: 'NETWORK_ERROR', PARSE_ERROR: 'PARSE_ERROR', @@ -24,17 +22,6 @@ export const ApiErrorCode = { export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode] -const STATUS_FALLBACK_CODE: Record = { - 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> = { [ApiErrorCode.NETWORK_ERROR]: '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 } | 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 | undefined { - if (raw === null || typeof raw !== 'object') { - return undefined - } - const out: Record = {} - for (const [k, v] of Object.entries(raw as Record)) { - if (typeof v === 'string') { - out[k] = v - } - } - return Object.keys(out).length > 0 ? out : undefined -} - -export function transformAxiosError(error: AxiosError): 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' { interface Register { defaultError: ApiError