feat(canary): Zod schemas + canary envelope error normalization
- Add Zod 4 schemas mirroring backend handler shapes:
- api/types/token.ts: tokenResponseSchema, manageTokenViewSchema,
artifactSchema (discriminatedUnion on kind), typeDescriptorSchema,
createTokenRequestSchema (with superRefine for telegram/webhook
conditional required), per-type metadata helpers.
- api/types/event.ts: eventResponseSchema with GeoView, NotifyStatus,
manageResponseSchema with cursor pagination.
- api/types/error.ts: apiErrorEnvelopeSchema (canary {success,error}
envelope) + successEnvelope<T> factory for typed unwrap.
- Refactor core/api/errors.ts: ApiError now carries canary error codes
(VALIDATION_ERROR, TURNSTILE_FAILED, BAD_CURSOR, etc.) and a
fields: Record<string,string> map. transformAxiosError parses the
canary envelope first, falls back to HTTP status code if envelope
shape is missing.
- Update core/api/query.config.ts no-retry list for canary codes
(user-error codes plus RATE_LIMITED to avoid burning rate budget).
- Remove obsolete template apiClient at core/api/api.config.ts; the
new canary client lives at api/client.ts (Phase 14 next commit).
This commit is contained in:
parent
4d0d151569
commit
db98a0d497
|
|
@ -0,0 +1,32 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// error.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import { type ZodType, z } from 'zod'
|
||||||
|
|
||||||
|
export const apiErrorPayloadSchema = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
fields: z.record(z.string(), z.string()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ApiErrorPayload = z.infer<typeof apiErrorPayloadSchema>
|
||||||
|
|
||||||
|
export const apiErrorEnvelopeSchema = z.object({
|
||||||
|
success: z.literal(false),
|
||||||
|
error: apiErrorPayloadSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ApiErrorEnvelope = z.infer<typeof apiErrorEnvelopeSchema>
|
||||||
|
|
||||||
|
export const successEnvelope = <T extends ZodType>(data: T) =>
|
||||||
|
z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SuccessEnvelope<T> = {
|
||||||
|
success: true
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// event.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { manageTokenViewSchema } from './token'
|
||||||
|
|
||||||
|
export const notifyStatusSchema = z.enum(['pending', 'sent', 'failed', 'deduped'])
|
||||||
|
|
||||||
|
export type NotifyStatus = z.infer<typeof notifyStatusSchema>
|
||||||
|
|
||||||
|
export const geoViewSchema = z.object({
|
||||||
|
country: z.string().nullable(),
|
||||||
|
region: z.string().nullable(),
|
||||||
|
city: z.string().nullable(),
|
||||||
|
asn: z.number().int().nullable(),
|
||||||
|
asn_org: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type GeoView = z.infer<typeof geoViewSchema>
|
||||||
|
|
||||||
|
export const eventResponseSchema = z.object({
|
||||||
|
id: z.number().int().nonnegative(),
|
||||||
|
triggered_at: z.iso.datetime(),
|
||||||
|
source_ip: z.string(),
|
||||||
|
user_agent: z.string().nullable(),
|
||||||
|
referer: z.string().nullable(),
|
||||||
|
geo: geoViewSchema,
|
||||||
|
extra: z.unknown(),
|
||||||
|
notify_status: notifyStatusSchema,
|
||||||
|
notified_at: z.iso.datetime().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type EventResponse = z.infer<typeof eventResponseSchema>
|
||||||
|
|
||||||
|
export const managePageSchema = z.object({
|
||||||
|
next_cursor: z.string(),
|
||||||
|
has_more: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ManagePage = z.infer<typeof managePageSchema>
|
||||||
|
|
||||||
|
export const manageResponseSchema = z.object({
|
||||||
|
token: manageTokenViewSchema,
|
||||||
|
events: z.array(eventResponseSchema),
|
||||||
|
events_total: z.number().int().nonnegative(),
|
||||||
|
events_silenced_active: z.number().int().nonnegative(),
|
||||||
|
page: managePageSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ManageResponse = z.infer<typeof manageResponseSchema>
|
||||||
|
|
@ -2,3 +2,7 @@
|
||||||
// ©AngelaMos | 2026
|
// ©AngelaMos | 2026
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
export * from './error'
|
||||||
|
export * from './event'
|
||||||
|
export * from './token'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// token.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const tokenTypeSchema = z.enum([
|
||||||
|
'webbug',
|
||||||
|
'slowredirect',
|
||||||
|
'docx',
|
||||||
|
'pdf',
|
||||||
|
'kubeconfig',
|
||||||
|
'envfile',
|
||||||
|
'mysql',
|
||||||
|
])
|
||||||
|
|
||||||
|
export type TokenType = z.infer<typeof tokenTypeSchema>
|
||||||
|
|
||||||
|
export const alertChannelSchema = z.enum(['telegram', 'webhook'])
|
||||||
|
|
||||||
|
export type AlertChannel = z.infer<typeof alertChannelSchema>
|
||||||
|
|
||||||
|
export const artifactKindSchema = z.enum([
|
||||||
|
'url',
|
||||||
|
'file',
|
||||||
|
'text',
|
||||||
|
'connection_string',
|
||||||
|
])
|
||||||
|
|
||||||
|
export type ArtifactKind = z.infer<typeof artifactKindSchema>
|
||||||
|
|
||||||
|
export const tokenResponseSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
manage_id: z.string(),
|
||||||
|
type: tokenTypeSchema,
|
||||||
|
memo: z.string(),
|
||||||
|
filename: z.string().nullable(),
|
||||||
|
alert_channel: alertChannelSchema,
|
||||||
|
created_at: z.iso.datetime(),
|
||||||
|
trigger_count: z.number().int().nonnegative(),
|
||||||
|
last_triggered: z.iso.datetime().nullable(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
trigger_url: z.string(),
|
||||||
|
manage_url: z.string(),
|
||||||
|
metadata: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TokenResponse = z.infer<typeof tokenResponseSchema>
|
||||||
|
|
||||||
|
export const manageTokenViewSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: tokenTypeSchema,
|
||||||
|
memo: z.string(),
|
||||||
|
filename: z.string().nullable(),
|
||||||
|
alert_channel: alertChannelSchema,
|
||||||
|
created_at: z.iso.datetime(),
|
||||||
|
trigger_count: z.number().int().nonnegative(),
|
||||||
|
last_triggered: z.iso.datetime().nullable(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
trigger_url: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ManageTokenView = z.infer<typeof manageTokenViewSchema>
|
||||||
|
|
||||||
|
const artifactUrlSchema = z.object({
|
||||||
|
kind: z.literal('url'),
|
||||||
|
url: z.string().optional(),
|
||||||
|
destination_url: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const artifactFileSchema = z.object({
|
||||||
|
kind: z.literal('file'),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
content_type: z.string().optional(),
|
||||||
|
content_b64: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const artifactTextSchema = z.object({
|
||||||
|
kind: z.literal('text'),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
content_type: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const artifactConnectionStringSchema = z.object({
|
||||||
|
kind: z.literal('connection_string'),
|
||||||
|
connection_string: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const artifactSchema = z.discriminatedUnion('kind', [
|
||||||
|
artifactUrlSchema,
|
||||||
|
artifactFileSchema,
|
||||||
|
artifactTextSchema,
|
||||||
|
artifactConnectionStringSchema,
|
||||||
|
])
|
||||||
|
|
||||||
|
export type Artifact = z.infer<typeof artifactSchema>
|
||||||
|
|
||||||
|
export const typeDescriptorSchema = z.object({
|
||||||
|
type: tokenTypeSchema,
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
artifact_kind: artifactKindSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TypeDescriptor = z.infer<typeof typeDescriptorSchema>
|
||||||
|
|
||||||
|
export const typeDescriptorListSchema = z.array(typeDescriptorSchema)
|
||||||
|
|
||||||
|
export type TypeDescriptorList = z.infer<typeof typeDescriptorListSchema>
|
||||||
|
|
||||||
|
const MEMO_MAX_LENGTH = 256
|
||||||
|
const FILENAME_MAX_LENGTH = 128
|
||||||
|
|
||||||
|
export const createTokenRequestSchema = z
|
||||||
|
.object({
|
||||||
|
type: tokenTypeSchema,
|
||||||
|
memo: z.string().max(MEMO_MAX_LENGTH).default(''),
|
||||||
|
filename: z.string().max(FILENAME_MAX_LENGTH).optional(),
|
||||||
|
alert_channel: alertChannelSchema,
|
||||||
|
telegram_bot: z.string().optional(),
|
||||||
|
telegram_chat: z.string().optional(),
|
||||||
|
webhook_url: z.url().optional(),
|
||||||
|
metadata: z.unknown().optional(),
|
||||||
|
cf_turnstile_response: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.alert_channel === 'telegram') {
|
||||||
|
if (!value.telegram_bot) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['telegram_bot'],
|
||||||
|
message: 'required when alert_channel is telegram',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!value.telegram_chat) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['telegram_chat'],
|
||||||
|
message: 'required when alert_channel is telegram',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.alert_channel === 'webhook' && !value.webhook_url) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['webhook_url'],
|
||||||
|
message: 'required when alert_channel is webhook',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateTokenRequest = z.infer<typeof createTokenRequestSchema>
|
||||||
|
export type CreateTokenInput = z.input<typeof createTokenRequestSchema>
|
||||||
|
|
||||||
|
export const createTokenResponseSchema = z.object({
|
||||||
|
token: tokenResponseSchema,
|
||||||
|
artifact: artifactSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateTokenResponse = z.infer<typeof createTokenResponseSchema>
|
||||||
|
|
||||||
|
export const slowredirectMetadataSchema = z.object({
|
||||||
|
destination_url: z.url(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SlowRedirectMetadata = z.infer<typeof slowredirectMetadataSchema>
|
||||||
|
|
||||||
|
export const envfileIncludeKeySchema = z.enum(['aws', 'stripe', 'github', 'db'])
|
||||||
|
|
||||||
|
export type EnvfileIncludeKey = z.infer<typeof envfileIncludeKeySchema>
|
||||||
|
|
||||||
|
export const envfileMetadataSchema = z.object({
|
||||||
|
include_keys: z.array(envfileIncludeKeySchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type EnvfileMetadata = z.infer<typeof envfileMetadataSchema>
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// ===================
|
|
||||||
// ©AngelaMos | 2026
|
|
||||||
// api.config.ts
|
|
||||||
// ===================
|
|
||||||
|
|
||||||
import axios, { type AxiosInstance } from 'axios'
|
|
||||||
|
|
||||||
const getBaseURL = (): string => {
|
|
||||||
return import.meta.env.VITE_API_URL ?? '/api'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const apiClient: AxiosInstance = axios.create({
|
|
||||||
baseURL: getBaseURL(),
|
|
||||||
timeout: 15000,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
withCredentials: true,
|
|
||||||
})
|
|
||||||
|
|
@ -1,110 +1,132 @@
|
||||||
/**
|
// ===================
|
||||||
* ©AngelaMos | 2026
|
// ©AngelaMos | 2026
|
||||||
* errors.ts
|
// errors.ts
|
||||||
*/
|
// ===================
|
||||||
|
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
|
|
||||||
export const ApiErrorCode = {
|
export const ApiErrorCode = {
|
||||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
PARSE_ERROR: 'PARSE_ERROR',
|
||||||
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
|
|
||||||
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
|
|
||||||
NOT_FOUND: 'NOT_FOUND',
|
|
||||||
CONFLICT: 'CONFLICT',
|
|
||||||
RATE_LIMITED: 'RATE_LIMITED',
|
|
||||||
SERVER_ERROR: 'SERVER_ERROR',
|
|
||||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||||
|
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||||
|
BAD_JSON: 'BAD_JSON',
|
||||||
|
BAD_CURSOR: 'BAD_CURSOR',
|
||||||
|
BAD_PARAM: 'BAD_PARAM',
|
||||||
|
UNKNOWN_TYPE: 'UNKNOWN_TYPE',
|
||||||
|
GENERATE_FAILED: 'GENERATE_FAILED',
|
||||||
|
TURNSTILE_FAILED: 'TURNSTILE_FAILED',
|
||||||
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
|
RATE_LIMITED: 'RATE_LIMITED',
|
||||||
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||||
|
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
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>> = {
|
||||||
|
[ApiErrorCode.NETWORK_ERROR]:
|
||||||
|
'Unable to reach the server. Check your connection.',
|
||||||
|
[ApiErrorCode.PARSE_ERROR]: 'Server response did not match expected shape.',
|
||||||
|
[ApiErrorCode.UNKNOWN_ERROR]: 'An unexpected error occurred.',
|
||||||
|
[ApiErrorCode.RATE_LIMITED]: 'Too many requests. Wait a moment, then retry.',
|
||||||
|
[ApiErrorCode.SERVICE_UNAVAILABLE]:
|
||||||
|
'Service is temporarily unavailable. Try again shortly.',
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly code: ApiErrorCode
|
readonly code: string
|
||||||
readonly statusCode: number
|
readonly statusCode: number
|
||||||
readonly details?: Record<string, string[]>
|
readonly fields?: Readonly<Record<string, string>>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
code: ApiErrorCode,
|
code: string,
|
||||||
statusCode: number,
|
statusCode: number,
|
||||||
details?: Record<string, string[]>
|
fields?: Record<string, string>
|
||||||
) {
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
this.code = code
|
this.code = code
|
||||||
this.statusCode = statusCode
|
this.statusCode = statusCode
|
||||||
this.details = details
|
this.fields = fields
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserMessage(): string {
|
getUserMessage(): string {
|
||||||
const messages: Record<ApiErrorCode, string> = {
|
if (this.message.length > 0) {
|
||||||
[ApiErrorCode.NETWORK_ERROR]:
|
return this.message
|
||||||
'Unable to connect. Please check your internet connection.',
|
|
||||||
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.',
|
|
||||||
[ApiErrorCode.AUTHENTICATION_ERROR]:
|
|
||||||
'Your session has expired. Please log in again.',
|
|
||||||
[ApiErrorCode.AUTHORIZATION_ERROR]:
|
|
||||||
'You do not have permission to perform this action.',
|
|
||||||
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.',
|
|
||||||
[ApiErrorCode.CONFLICT]:
|
|
||||||
'This operation conflicts with an existing resource.',
|
|
||||||
[ApiErrorCode.RATE_LIMITED]:
|
|
||||||
'Too many requests. Please wait a moment and try again.',
|
|
||||||
[ApiErrorCode.SERVER_ERROR]:
|
|
||||||
'Something went wrong on our end. Please try again later.',
|
|
||||||
[ApiErrorCode.UNKNOWN_ERROR]:
|
|
||||||
'An unexpected error occurred. Please try again.',
|
|
||||||
}
|
}
|
||||||
return messages[this.code]
|
return USER_FACING_COPY[this.code] ?? 'An unexpected error occurred.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiErrorResponse {
|
interface EnvelopeErrorShape {
|
||||||
detail?: string | { msg: string; type: string }[]
|
success?: unknown
|
||||||
message?: string
|
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 {
|
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
|
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
|
||||||
}
|
}
|
||||||
|
const { status, data } = error.response
|
||||||
|
|
||||||
const { status } = error.response
|
const envelope = parseEnvelopeError(data)
|
||||||
const data = error.response.data as ApiErrorResponse | undefined
|
if (envelope) {
|
||||||
let message = 'An error occurred'
|
return new ApiError(envelope.message, envelope.code, status, envelope.fields)
|
||||||
let details: Record<string, string[]> | undefined
|
|
||||||
|
|
||||||
if (data?.detail) {
|
|
||||||
if (typeof data.detail === 'string') {
|
|
||||||
message = data.detail
|
|
||||||
} else if (Array.isArray(data.detail)) {
|
|
||||||
details = { validation: [] }
|
|
||||||
data.detail.forEach((err) => {
|
|
||||||
details?.validation.push(err.msg)
|
|
||||||
})
|
|
||||||
message = 'Validation error'
|
|
||||||
}
|
|
||||||
} else if (data?.message) {
|
|
||||||
message = data.message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeMap: Record<number, ApiErrorCode> = {
|
const fallbackCode = STATUS_FALLBACK_CODE[status] ?? ApiErrorCode.UNKNOWN_ERROR
|
||||||
400: ApiErrorCode.VALIDATION_ERROR,
|
return new ApiError('Request failed', fallbackCode, status)
|
||||||
401: ApiErrorCode.AUTHENTICATION_ERROR,
|
|
||||||
403: ApiErrorCode.AUTHORIZATION_ERROR,
|
|
||||||
404: ApiErrorCode.NOT_FOUND,
|
|
||||||
409: ApiErrorCode.CONFLICT,
|
|
||||||
429: ApiErrorCode.RATE_LIMITED,
|
|
||||||
500: ApiErrorCode.SERVER_ERROR,
|
|
||||||
502: ApiErrorCode.SERVER_ERROR,
|
|
||||||
503: ApiErrorCode.SERVER_ERROR,
|
|
||||||
504: ApiErrorCode.SERVER_ERROR,
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR
|
|
||||||
|
|
||||||
return new ApiError(message, code, status, details)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-query' {
|
declare module '@tanstack/react-query' {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,5 @@
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
export * from './api.config'
|
|
||||||
export * from './errors'
|
export * from './errors'
|
||||||
export * from './query.config'
|
export * from './query.config'
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,21 @@ import { toast } from 'sonner'
|
||||||
import { QUERY_CONFIG } from '@/config'
|
import { QUERY_CONFIG } from '@/config'
|
||||||
import { ApiError, ApiErrorCode } from './errors'
|
import { ApiError, ApiErrorCode } from './errors'
|
||||||
|
|
||||||
const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [
|
const NO_RETRY_ERROR_CODES: ReadonlySet<string> = new Set<string>([
|
||||||
ApiErrorCode.AUTHENTICATION_ERROR,
|
|
||||||
ApiErrorCode.AUTHORIZATION_ERROR,
|
|
||||||
ApiErrorCode.NOT_FOUND,
|
|
||||||
ApiErrorCode.VALIDATION_ERROR,
|
ApiErrorCode.VALIDATION_ERROR,
|
||||||
] as const
|
ApiErrorCode.BAD_JSON,
|
||||||
|
ApiErrorCode.BAD_CURSOR,
|
||||||
|
ApiErrorCode.BAD_PARAM,
|
||||||
|
ApiErrorCode.UNKNOWN_TYPE,
|
||||||
|
ApiErrorCode.NOT_FOUND,
|
||||||
|
ApiErrorCode.TURNSTILE_FAILED,
|
||||||
|
ApiErrorCode.RATE_LIMITED,
|
||||||
|
ApiErrorCode.PARSE_ERROR,
|
||||||
|
])
|
||||||
|
|
||||||
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
|
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError && NO_RETRY_ERROR_CODES.has(error.code)) {
|
||||||
if (NO_RETRY_ERROR_CODES.includes(error.code)) {
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
|
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue