背景
#2814 把短信总量成本闸落在 SmsService.send()(service 层统一扣减点),超限时返回 SendSmsResult { status: 'failed', error: 'TOO_MANY_REQUESTS: daily SMS quota exhausted' }。诉求第 3 点要求 OTP / 邀请路径回 429 TOO_MANY_REQUESTS,与按号码冷却一致。
实测结论:这个语义到不了调用方,而且原因完全在 auth 端点侧,service 层无论怎么写都够不着。
实测证据
-
packages/plugins/plugin-auth/src/auth-manager.ts 的 deliverPhoneOtp():
if (result.status === 'failed') {
throw new Error(`Phone OTP could not be sent: ${result.error ?? 'SMS delivery failed'}`);
}
抛的是普通 Error,不是 APIError。sendPhoneInviteSms() 同理(throw new Error('Invitation SMS failed: …'))。
-
better-call@1.3.7(better-auth 的路由层)dist/router.mjs 第 93-98 行:
if (isAPIError(error)) return toResponse(error);
console.error(`# SERVER_ERROR: `, error);
return new Response(null, { status: 500, statusText: "Internal Server Error" });
isAPIError 的判定是 error instanceof APIError || error?.name === "APIError"(dist/utils.mjs:57)。
-
直接量:普通 Error 的 instanceof APIError 为 false;new APIError('TOO_MANY_REQUESTS').statusCode 为 429。
即:配额拒发 → 调用方拿到 HTTP 500,且响应体为 null(TOO_MANY_REQUESTS 只出现在服务端日志里)。同一端点上按号码闸走的是 hooks.before 里的 assertPhoneOtpSendAllowed(),它抛的是 APIError('TOO_MANY_REQUESTS'),正常回 429 —— 于是同一个端点上两道墙的对外表现不一致,正好是 #2814「两道墙从外面看应当一样」的反面。
建议修法(identity 车道)
在 deliverPhoneOtp / sendPhoneInviteSms 里识别 service 层信封上的 TOO_MANY_REQUESTS: 前缀,改抛 APIError('TOO_MANY_REQUESTS', …);其余失败仍按现状(传输故障回 500 是合理的)。文案沿用按号码闸的措辞,不泄露配额剩余细节。
@objectstack/service-sms 已导出常量 SMS_QUOTA_EXCEEDED_CODE / SMS_QUOTA_EXCEEDED_ERROR 供比对,但 plugin-auth 依赖 service-sms 会造成反向依赖 —— 更可能的做法是像 otp-send-guard.ts 对 normalizePhoneNumber 那样在本地写死这一个码并注明出处。
范围说明
本 issue 从 #2814 的实现中分出:#2814 的文件面被限定在 packages/services/**,⛔ 不动 plugin-auth,所以此处如实记录而非顺手改。#2814 的 PR 会写明「OTP 路径当前得到 500」这一事实。
Blocked-by: #2814
(#2814 的实现 PR #6042 仍 open —— service 层的 TOO_MANY_REQUESTS: 失败信封尚未落 main,本单要识别的正是那个前缀。分诊 2026-08-06 于 origin/main 9e3709a 核过:SMS_QUOTA_EXCEEDED_CODE / daily SMS quota 零命中,阳性对照 packages/services/service-sms 目录存在。)
Generated by Claude Code
背景
#2814 把短信总量成本闸落在
SmsService.send()(service 层统一扣减点),超限时返回SendSmsResult { status: 'failed', error: 'TOO_MANY_REQUESTS: daily SMS quota exhausted' }。诉求第 3 点要求 OTP / 邀请路径回429 TOO_MANY_REQUESTS,与按号码冷却一致。实测结论:这个语义到不了调用方,而且原因完全在 auth 端点侧,service 层无论怎么写都够不着。
实测证据
packages/plugins/plugin-auth/src/auth-manager.ts的deliverPhoneOtp():抛的是普通
Error,不是APIError。sendPhoneInviteSms()同理(throw new Error('Invitation SMS failed: …'))。better-call@1.3.7(better-auth 的路由层)dist/router.mjs第 93-98 行:isAPIError的判定是error instanceof APIError || error?.name === "APIError"(dist/utils.mjs:57)。直接量:普通
Error的instanceof APIError为false;new APIError('TOO_MANY_REQUESTS').statusCode为429。即:配额拒发 → 调用方拿到 HTTP 500,且响应体为
null(TOO_MANY_REQUESTS只出现在服务端日志里)。同一端点上按号码闸走的是hooks.before里的assertPhoneOtpSendAllowed(),它抛的是APIError('TOO_MANY_REQUESTS'),正常回 429 —— 于是同一个端点上两道墙的对外表现不一致,正好是 #2814「两道墙从外面看应当一样」的反面。建议修法(identity 车道)
在
deliverPhoneOtp/sendPhoneInviteSms里识别 service 层信封上的TOO_MANY_REQUESTS:前缀,改抛APIError('TOO_MANY_REQUESTS', …);其余失败仍按现状(传输故障回 500 是合理的)。文案沿用按号码闸的措辞,不泄露配额剩余细节。@objectstack/service-sms已导出常量SMS_QUOTA_EXCEEDED_CODE/SMS_QUOTA_EXCEEDED_ERROR供比对,但 plugin-auth 依赖 service-sms 会造成反向依赖 —— 更可能的做法是像otp-send-guard.ts对normalizePhoneNumber那样在本地写死这一个码并注明出处。范围说明
本 issue 从 #2814 的实现中分出:#2814 的文件面被限定在
packages/services/**,⛔ 不动 plugin-auth,所以此处如实记录而非顺手改。#2814 的 PR 会写明「OTP 路径当前得到 500」这一事实。Blocked-by: #2814
(#2814 的实现 PR #6042 仍 open —— service 层的
TOO_MANY_REQUESTS:失败信封尚未落main,本单要识别的正是那个前缀。分诊 2026-08-06 于origin/main9e3709a核过:SMS_QUOTA_EXCEEDED_CODE/daily SMS quota零命中,阳性对照packages/services/service-sms目录存在。)Generated by Claude Code