errors.js
3.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
/**
* `registerErrorHandler` —— 全局错误成形:Valibot 校验失败 → `400` + 结构化 issues;业务
* 错误按 `err.statusCode` 或 `err.code`→HTTP 状态表映射;其余 → `500`。
*
* 错误码→HTTP 状态是唯一一张表(`CODE_STATUS`),两个 server 合并去重后共用。业务错误
* (`status < 500`)若携带 `err.details` 且为对象则透传(vg 版行为,保留)。
*/
import { ValiError, parse as vparse } from 'valibot'
import { ApiError } from '@drama/contracts/platform'
/**
* 错误码 → HTTP 状态单表(drama + visual-game 两 server 合并去重)。
* 无显式 `err.statusCode` 时按 `err.code` 推断,避免业务拒绝退化成 500。
* @type {Record<string, number>}
*/
export const CODE_STATUS = {
// 认证 / 授权
INVALID_CREDENTIALS: 401,
USER_NOT_FOUND: 401,
UNAUTHORIZED: 401,
TOKEN_EXPIRED: 401,
TOKEN_INVALID: 401,
ERR_JWT_EXPIRED: 401,
REFRESH_REUSED: 401,
// 冲突
USER_EXISTS: 409,
EMAIL_TAKEN: 409,
CONFLICT: 409,
SAVE_SLOT_CONFLICT: 409,
// 限流
RATE_LIMITED: 429,
TOO_MANY_ATTEMPTS: 429,
// 通用
FORBIDDEN: 403,
NOT_FOUND: 404,
BAD_REQUEST: 400,
CONTENT_FILTER: 400,
AI_OUTPUT_INVALID: 502,
// 校验 / 验证码
EMAIL_VERIFICATION_REQUIRED: 400,
CODE_INVALID: 400,
CODE_EXPIRED: 400,
// 计费 / 支付
INSUFFICIENT_BALANCE: 402,
PRODUCT_NOT_FOUND: 404,
RULE_NOT_FOUND: 404,
ORDER_NOT_FOUND: 404,
IAP_NOT_CONFIGURED: 400,
IAP_VERIFY_FAILED: 400,
STRIPE_NOT_CONFIGURED: 400,
STRIPE_ERROR: 502,
WEBHOOK_SIGNATURE_INVALID: 400,
// waiting list 预期业务失败
'waitlist.invalid_input': 400,
'waitlist.invalid_email': 400,
'waitlist.invalid_source': 400,
'waitlist.invalid_status': 400,
'waitlist.invalid_limit': 400,
'waitlist.invalid_cursor': 400,
'waitlist.consent_required': 400,
'waitlist.invite_expired': 400,
'waitlist.invite_email_mismatch': 400,
'waitlist.invite_not_usable': 400,
'waitlist.account_already_registered': 409,
'waitlist.duplicate_email': 409,
'waitlist.invalid_status_transition': 409,
'waitlist.entry_not_found': 404,
'waitlist.delivery_not_found': 404,
'waitlist.invite_not_found': 404,
'waitlist.invite_delivery_failed': 502,
}
/**
* @param {import('fastify').FastifyInstance} app
*/
export function registerErrorHandler(app) {
app.setErrorHandler((/** @type {any} */ err, request, reply) => {
if (err instanceof ValiError) {
reply.code(400)
return reply.send(
vparse(ApiError, {
error: {
code: 'validation_error',
message: 'Request validation failed',
retryable: false,
details: { issues: err.issues.map((i) => ({ path: pathOf(i), message: i.message })) },
},
}),
)
}
const status =
typeof err.statusCode === 'number'
? err.statusCode
: (err.code && CODE_STATUS[err.code]) || 500
const requestId = request?.id
const traceId = request?.ctx?.traceId
const details = status < 500 && err.details && typeof err.details === 'object' ? err.details : {}
const traceDetails = {
...details,
...(traceId ? { traceId } : {}),
...(requestId ? { requestId } : {}),
}
if (request && request.log_ && typeof request.log_.error === 'function') {
request.log_.error('request failed', { err, status, traceId, requestId })
}
reply.code(status)
return reply.send(
vparse(ApiError, {
error: {
code: err.code && typeof err.code === 'string' ? err.code : 'internal_error',
message: status >= 500 ? 'Internal server error' : String(err.message || 'Error'),
retryable: status >= 500,
...(Object.keys(traceDetails).length > 0 ? { details: traceDetails } : {}),
},
}),
)
})
}
/**
* @param {any} issue
* @returns {string}
*/
function pathOf(issue) {
if (!issue.path) return ''
return issue.path.map((/** @type {any} */ p) => p.key).join('.')
}