Skip to content

fix(plugin-auth): 短信日配额拒发时 OTP / 邀请短信回 429,不再是 500 + 空响应体 (#6039) - #6092

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-6039-otp-quota-429
Aug 7, 2026
Merged

fix(plugin-auth): 短信日配额拒发时 OTP / 邀请短信回 429,不再是 500 + 空响应体 (#6039)#6092
baozhoutao merged 1 commit into
mainfrom
claude/issue-6039-otp-quota-429

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6039

缺陷

#2814(PR #6042)把短信总量成本闸落在 SmsService.send()。它是内核服务,不知道调用方是谁,
所以超限时返回一条失败结果,把码写在服务层既有的 CODE: message 信封上 ——
TOO_MANY_REQUESTS: daily SMS quota exhausted(packages/services/service-sms/src/sms-daily-quota.ts)。
把 HTTP 语义还原回去是 auth 端点的职责,而 AuthManager 此前没有做:deliverPhoneOtp() /
sendPhoneInviteSms() 对任何 status === 'failed' 一律抛普通 Error

better-call(better-auth 的路由层)只把 APIError 映射成真实状态码 ——
isAPIError = err instanceof APIError || err?.name === "APIError"
(better-call@1.3.7 dist/utils.mjs:57,消费点 dist/router.mjs:93),其余一律走
console.error + 500、响应体 null 的分支。于是配额拒发对外是 500,TOO_MANY_REQUESTS
只留在服务端日志里;而同一个端点上按号码冷却闸(assertPhoneOtpSendAllowed,在
admission hook 里)抛的是 APIError('TOO_MANY_REQUESTS'),正常回 429 —— 一个端点两种口径,
正是 #2814「两道墙从外面看应当一样」的反面。

改法

两处失败分支先识别信封上的 TOO_MANY_REQUESTS: 前缀,改抛 APIError('TOO_MANY_REQUESTS');
其余失败原样保持。

只有码跨包,且是本地重述。 识别用的 TOO_MANY_REQUESTSauth-manager.ts 本地写死
(SMS_QUOTA_EXCEEDED_CODE),注释指向出处 packages/services/service-sms/src/sms-daily-quota.ts
不 import 的理由是实测的反向依赖:service-sms 已经依赖本包 ——
packages/services/service-sms/src/sms-daily-quota.ts:69-73@objectstack/plugin-auth
import InProcessCounterStore / incrementFixedWindow;反向 import 会成环。这与
service-smsnormalizeSmsRecipient 就地重述 plugin-auth 形状规则
(sms-service.ts:18「kept local: the two packages must not depend on each other」)
是同一个取舍的另一半。跨包重述的只是一个 ADR-0112 闭集错误码,冒号后的措辞归服务层所有。

匹配是前缀,不是子串。 传输故障把 provider 原文放进 error
(sms-service.ts 末尾按 500 字截断后原样回传),一条只在句中提到该码的 provider 报错仍然是
传输故障 —— 500,不是 429。

文案不泄露预算。 沿按号码闸的措辞形状,不含上限、剩余量与重置时刻。按号码闸报自己的
重试窗口是因为它算得出;配额闸不承诺它给不出的时间。

反向验证(先定方向,再跑)

预判:配额路径 before-red / after-green;「不过度收紧」两钉 两轮皆绿(它们钉的是
不变的行为,唯一有意义的方向就是两轮都绿 —— 若第一轮就红,说明我改错了范围)。实测吻合。

pnpm --filter '@objectstack/plugin-auth' test(全包 38 文件 / 902 用例,两轮同一命令):

改动前:  Test Files  1 failed | 37 passed (38)
          Tests       5 failed | 897 passed (902)

改动后:  Test Files  38 passed (38)
          Tests       902 passed (902)

新增 7 个用例,改动前 5 红 2 绿:

  • 红 → 绿:OTP 路径抛 APIError / 429 / TOO_MANY_REQUESTS;邀请短信路径同上;
    两道墙对外形状一致钉;码前缀匹配(服务层可自由改写冒号后的措辞);文案无配额数字。
  • 两轮皆绿:传输故障(provider down)仍抛普通 ErrorisAPIErrorfalse;
    仅在句中提到该码的 provider 报错同样保持普通 Error

改动前的红有代表性的一条,直接读出缺陷:

AssertionError: expected { name: 'Error', …(3) } to deeply equal { name: 'APIError', …(3) }
-   "name": "APIError",  "status": "TOO_MANY_REQUESTS",  "statusCode": 429,
+   "name": "Error",     "status": undefined,            "statusCode": undefined,

一致性钉的断言方式:同一个 manager 上把两道墙各触发一次,把
{ name, status, statusCode, body?.code } 取出来 toEqual 互比,再比一次字面量
{ name: 'APIError', status: 'TOO_MANY_REQUESTS', statusCode: 429, bodyCode: undefined }
message 文本故意不进这个形状 —— 两道墙必须在码与状态上不可区分(#2814:攻击者不该
知道自己撞的是哪个预算),但各自仍可说各自为真的话。

一处如实记录:邀请短信路径的观测面比模板预设的窄

模板预设两条路径都是「500 → 429」。OTP 路径确实如此。邀请短信路径不是:仓内唯一调用方
admin-import-users.ts:464-471 按行捕获这个抛出,记成 INVITE_SMS_FAILED 并继续,
HTTP 响应本来就是 200 而非 500 —— 所以那条路径上变的不是状态码,而是行内报错不再携带
服务层原始信封。改动仍然正确且必要:sendPhoneInviteSmsAuthManager公开方法,
任何直接把它的 rejection 交给路由层的调用方(含仓外 host、未来端点)都要拿到 429。
测试因此把断言下沉到 AuthManager 边界(抛出物本身),而不是假装存在一个 500→429 的
HTTP 观测。

其它


Generated by Claude Code

`SmsService.send()` reports the deployment's daily SMS quota being hit as a
failed result carrying the service's `CODE: message` envelope
(`TOO_MANY_REQUESTS: daily SMS quota exhausted`, #2814) — it is a kernel
service and cannot throw an HTTP-shaped error. `AuthManager` never turned that
back into one: `deliverPhoneOtp()` / `sendPhoneInviteSms()` rethrew every
`status === 'failed'` as a plain `Error`, and better-call maps only `APIError`
to a real status (`isAPIError`, better-call@1.3.7 dist/utils.mjs:57 →
dist/router.mjs:93), so the caller got 500 with a null body while the
per-number wall on the same endpoint answered 429.

Both failure branches now recognise the `TOO_MANY_REQUESTS:` prefix and throw
`APIError('TOO_MANY_REQUESTS')`. Only the ADR-0112 code crosses the package
boundary — restated locally, because `@objectstack/service-sms` already
depends on plugin-auth (mirror of `normalizeSmsRecipient` there). Transport
failures keep their plain `Error` / 500 semantics, and the 429 message carries
no ceiling, remaining count or reset clock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 12:50am

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 01:01
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit db8c285 Aug 7, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-6039-otp-quota-429 branch August 7, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(plugin-auth): 短信日配额拒发时 OTP 端点回 500 而非 429 —— deliverPhoneOtp 抛的是普通 Error

2 participants