API 目前处于 Early Preview 阶段, 可能存在诸多不完善与错误之处,欢迎加群交流反馈:加入官方交流群(1073434398)
错误与排障
协议错误码原文、同意页/内嵌的拒绝理由、业务接口的状态码语义,以及按症状反查的排查表。
协议错误体
/v1/oauth/** 与 /v1/oidc/** 的失败响应恒为两键,不套 v1 信封:
{ "error": "invalid_client", "error_description": "Unknown or inactive OAuth client" }error | 常见 HTTP | 典型 error_description | 含义 |
|---|---|---|---|
invalid_client | 401 | Unknown or inactive OAuth client / Invalid client credentials | 客户端不存在、未过审、被停用,或 secret 错 |
invalid_request | 400 | Use only one client authentication method | Basic 与 body secret 同时传了 |
invalid_request | 400 | code is required / refresh_token is required / device_code is required | 少了必填参数 |
invalid_request | 400 | Invalid or already used authorization code | code 用过、伪造、或换了 client |
invalid_request | 400 | Authorization code has expired | 超过 10 分钟 |
invalid_request | 400 | redirect_uri mismatch | 换令牌时的回调与授权时不一致 |
invalid_request | 400 | code_verifier is required (PKCE S256) / PKCE verification failed | PKCE 缺失或不匹配 |
invalid_request | 400 | code is bound to a different DPoP key | 换了 DPoP 密钥 |
invalid_request | 400 | Unsupported grant_type: <值> | grant 类型不支持 |
invalid_request | 400 | Unsupported scope: <值> | 请求了未注册/不存在的 scope |
invalid_request | 400 | redirect_uri is not registered for this client | 回调地址没登记过 |
invalid_request | 400 | Only response_type=code is supported | 用了别的 response_type |
invalid_request | 400 | PKCE S256 code_challenge is required | 公开客户端没带 challenge 或用了 plain |
invalid_grant | 401 | Refresh token is invalid or expired / Refresh token does not belong to this client | 刷新令牌无效/换客户端用 |
invalid_grant | 401 | Refresh token replay detected; token family revoked | 重放:整族已撤销,必须重新授权 |
invalid_grant | 401 | Token family has been revoked | 族被撤(登出/撤销/泄露处理) |
invalid_grant | 403 | Account is disabled | 账号被禁用 |
temporarily_unavailable | 429 / 503 | (限流或依赖不可用) | 按 Retry-After 退避后重试 |
server_error | 500 | Internal error | 服务端故障,附带 requestId 可用于上报 |
invalid_dpop_proof | 401 | proof 相关文案 | DPoP 校验失败;看 WWW-Authenticate 与 DPoP-Nonce |
invalid_token | 200 | The access token is invalid or expired | userinfo 专用:失败也回 200 |
不要在客户端按 error_description 分支
error_description 是给人看的,文案可能微调;请按 error 分支,error_description 只用于日志与提示。
同意页 / 内嵌的拒绝理由
内嵌模式(/oauth/embed)在渲染前就做完预校验,失败会以 deny= 回给页面,页面再把理由
postMessage 给宿主:
| 理由 | 含义 |
|---|---|
invalid_request | 缺 client_id 或 response_type 不对 |
invalid_client | 客户端不存在 / 未过审 / 被停用 |
redirect_uri_not_registered | redirect_uri 没登记或为空 |
scope_not_allowed | 请求的 scope 超出该客户端允许集 |
pkce_required | 公开客户端没带 code_challenge |
state_required | 内嵌模式 state 必填 |
prompt_none_unsupported | 内嵌模式不支持 prompt=none |
origin_not_registered | 宿主 origin 没登记进 embed_origins |
service_unavailable | 依赖未就绪(稍后重试) |
JS SDK 侧的错误码(not_configured / popup_blocked / timeout …)见前端接入。
业务接口状态码
| 状态 | 含义 | 处理 |
|---|---|---|
| 200 / 201 | 成功(读 / 建号) | — |
| 202 | 已接受(如删除申请) | 看 code:common.accepted = 删除申请受理 |
| 401 | 明确未认证 | 刷新令牌;刷新失败重新授权 |
| 403 | 已认证但无权:scope 不足、要求浏览器会话、该形状只给第一方、写入被暂停、删除要用户同意 | 看业务码:只有"缺 scope"能靠重新授权解决,其余三种不能靠重试 |
| 404 | 路径不存在或资源不存在 | 检查路径拼写与版本前缀(/v0、/v1) |
| 409 | 冲突(幂等键参数不一致、确认令牌过期/已用/payload 变了等) | 换参数或重新发起,不要原样重试 |
| 413 | 请求体过大(上限 1 MiB) | 分批提交 |
| 426 | 客户端版本过低(仅原生 App 登录态) | 升级 App |
| 428 | 需要用户先做一次确认:写入确认(游戏面)或敏感操作二次验证(浏览器会话) | 游戏面按 data.confirmation 重放;会话面走 step-up 流程 |
| 429 | 限流 | 按 Retry-After 退避 |
| 503 | 鉴权或数据服务暂不可用 | 可重试,不要清会话 |
/v1/** 的错误体是信封:
{
"code": "auth.unauthenticated",
"message": "Authentication is required",
"data": null,
"requestId": "req_…"
}失败响应里 data 通常为 null
唯一例外是 428 game.writeConfirmationRequired:它必须带机读的 data.confirmation
(确认令牌),否则客户端没法完成确认重放。其余失败请只按 code 分支。
游戏面的四个专属业务码
/v1/games/** 的权限收紧带来四个新码。它们都会出现在公开的
GET /v1/catalog/codes 里,所以文案里不含任何阈值或判定细节 —— 也别试图从文案反推规则。
code | HTTP | 中文文案 | English | 什么时候出现 | 怎么处理 |
|---|---|---|---|---|---|
game.firstPartyOnly | 403 | 该操作只能由 RankHub 客户端发起 | This operation is only available to the RankHub client | 第三方令牌调了仅第一方的形状 | 换用对第三方开放的 /v1/games/** 形状;不要重试 |
game.deletionConsentRequired | 403 | 删除档案需要用户在 RankHub 中同意 | Deleting a profile requires the user's approval in RankHub | 第三方直接 DELETE 档案 | 改用 POST .../deletion-requests,等用户确认 |
game.writeSuspended | 403 | 该应用的写权限已被暂停 | Write access for this application is suspended | 该应用的全部写接口(读与登录不受影响) | 停下排查数据来源,联系平台恢复 |
game.writeConfirmationRequired | 428 | 该写入需要用户确认后重试 | This write requires user confirmation | 写入被判定为"低频、单次可疑" | 把 data.confirmation.approveUrl 交给用户,确认后用 confirmationToken 重放同一份 payload |
game.firstPartyOnly 只出现在仅第一方开放的形状上(以及第三方打 /v0 面时,
曲目别名与谱面评论两族除外)。完整清单见权限范围与可用 API。
428 的 data 形状:
{
"code": "game.writeConfirmationRequired",
"message": "该写入需要用户确认后重试",
"data": {
"confirmation": {
"confirmationToken": "wc_7Q2…",
"approveUrl": "rankhub://write-confirmation?token=wc_…",
"expiresAt": "2026-10-22T19:10:00Z"
}
},
"requestId": "req_…"
}重放细节(一次性、30 分钟、payload 变了就 409 common.conflict)见
成绩读取与写入。
删除申请的成功码:common.accepted
删除申请成功返回 202 + code: "common.accepted"("已接受")。
别把它当成失败:它表示"申请已受理,等用户处理",data 里有 requestId 与 expiresAt。
别把 202 当 200
异步提交成功是 202;删除申请受理的 code 是 common.accepted。
按 code 分支,不要只看 status === 202。
限流码
限流命中的错误信封是 429 + code: "common.rateLimited"(retryable: true),
并带 Retry-After(秒)。按它退避(带抖动);单批条数超限是 400 common.invalidArgument,
不是限流。详见业务 API 总览。
按症状查
| 症状 | 最可能的原因 | 怎么确认 |
|---|---|---|
| 授权页说应用不可用 | 应用还在「审核中」/被停用 | 开发者平台看状态 |
授权页 400 redirect_uri is not registered | 回调地址不完全一致(末尾斜杠、端口、大小写、http vs https) | 把两串贴一起逐字符比 |
授权页 400 PKCE S256 code_challenge is required | 公开客户端(none)没带 PKCE | 加 code_challenge + method=S256 |
换令牌 401 invalid_client | secret 错 / 认证方式与注册不符 / Basic 与 body 混用 | 只用一种方式;确认平台里选的是 basic 还是 post |
换令牌 400 Invalid or already used authorization code | code 用第二次、超时、或换令牌时换了 client_id | 一次授权只换一次 |
| 换令牌成功但调接口 401 | 用了 api.rankhub.cc 以外的地址,或令牌已过期 | 只认 api.rankhub.cc;看 expires_in |
调接口 403 requires <scope> | scope 不够 | 平台补 scope → 用户重新授权 |
调了只给第一方的形状(403 game.firstPartyOnly) | 该形状不对第三方开放 | 不是路径写错,也不是缺 scope;改用 /v1/games/** 的开放形状 |
调 /v0/games/**、/v0/social/** 403 game.firstPartyOnly | 旧面本期只服务第一方(别名 / 评论例外) | 迁到 /v1/games/**;社交面暂时没有第三方替代 |
建号 POST /v1/games/{game}/profiles 返回 400 | 缺该游戏的建号材料,或该游戏要从"已绑定账号"拉取而用户没绑 | 对材料表逐个字段核对(sessionToken / playerFileV2 / phone+code / externalId+snapshot);notanote / musedash / osu 只需用户已绑定,见游戏档案 |
建号时传了 scores 但成绩没变 | 该游戏 pushScores 为假,scores 被忽略(不是报错) | 改用 POST .../{profileId}/scores 逐条推 |
删档案 403 game.deletionConsentRequired | 第三方不能直删 | 改用 POST .../deletion-requests |
写成绩 403 game.writeSuspended | 该应用写权限被暂停 | 应用详情看原因;联系平台,重试无效 |
写成绩 428 game.writeConfirmationRequired | 这次写入要用户确认 | 用 data.confirmation.approveUrl 让用户确认,再带 confirmationToken 重放 |
带 confirmationToken 重放却 409 common.conflict | 令牌过期 / 已用 / payload 变了 | 重放必须与上次完全一致;改了就重新发起 |
| 某个用户突然全部掉登录 | 刷新令牌重放(并发刷新)导致整族撤销 | 查你的刷新是否串行 |
id_token 验签失败 | JWKS 为空 / iss 不匹配 / 算法不在白名单 | 直接 curl JWKS;核对 iss 是 https://auth.rankhub.cc |
| 通用 OIDC 库初始化就失败 | 发现文档不在标准路径 | 显式配置发现地址 |
| userinfo 返回 200 但没有用户 | 它在 body 里回了 error: invalid_token | 检查 body 的 error 字段 |
内嵌 8 秒后 origin_not_registered | 来源没登记 / 被 frame-ancestors 拦 | 联系我们登记 embed_origins |
设备码流卡在 authorization_pending | 用户还没在确认页/App 里完成确认 | 让用户打开 verification_uri 输入 user_code |
上报问题要带什么
- 出问题的接口(哪个 URL、哪个步骤);
- 时间(精确到分钟,带时区);
- 响应里的
X-Request-ID(或在信封里的requestId)—— 服务端日志按它索引; error/error_description原文;- 不要贴
access_token/refresh_token/client_secret/ 授权码 / DPoP proof。
服务端日志只记录 requestId、主体哈希、jkt 哈希,不会记录令牌、proof、nonce 或验证码。