Errors
Errors follow the RFC 6749 shape — an error code and, usually, an
error_description:
{"error": "invalid_grant", "error_description": "授权码已过期"}
Two things to know before you write any handling code:
error_descriptionis Chinese, and it is not a stable interface. Branch onerrorand on the HTTP status. Use the description for your own logs only, and never show it to an end user.- Descriptions are not unique per cause. Several distinct failures share one
errorcode. Where that matters, it is called out below.
Authorization request
GET /oauth/authorize returns these as JSON in the browser. They are not
redirected to your callback, so a user hitting one sees a raw JSON page — which
means these are bugs in your integration to be fixed before launch, not runtime
conditions to handle.
| Status | error | error_description | Cause |
|---|---|---|---|
| 400 | invalid_request | 缺少 client_id 或 redirect_uri 参数 | client_id or redirect_uri missing or empty |
| 400 | unsupported_response_type | 仅支持 response_type=code | response_type present and not code |
| 400 | invalid_client | 应用不存在或已被禁用 | Unknown client_id, or the application is disabled |
| 400 | invalid_redirect_uri | 回调地址未注册 | redirect_uri is not byte-identical to a registered URI |
| 400 | invalid_scope | scope 'X' 不被允许 | X is outside your permitted set. An empty X means your scope string had a double, leading or trailing space. |
Checks run in that order, so you see the first failure only. Fix and retry.
Errors delivered to your callback
Once the user is on the consent screen, failures come back as query parameters
on your redirect URI, with state re-appended.
error | Meaning | What to do |
|---|---|---|
access_denied | The user declined | Return them to your app; do not retry automatically |
login_required | prompt=none and the user is not signed in | Retry without prompt=none |
consent_required | prompt=none and no matching grant exists | Retry without prompt=none |
interaction_required | prompt=none and the grant check failed | Retry without prompt=none |
Token endpoint
POST /oauth/token.
| Status | error | error_description | Cause |
|---|---|---|---|
| 400 | unsupported_grant_type | — | grant_type is absent or not one of the two supported values. Also what you get when you send a JSON body — the fields are never read. |
| 401 | invalid_client | — | Unknown client_id, or a wrong client_secret. The two are not distinguished. |
| 400 | invalid_grant | 授权码无效或已使用 | The code is unknown, belongs to a different client, or was already redeemed |
| 400 | invalid_grant | 授权码已过期 | More than 10 minutes since the code was issued |
| 400 | invalid_grant | redirect_uri 不匹配 | The redirect_uri differs from the one sent to /oauth/authorize |
| 400 | invalid_grant | 需要 code_verifier | The code was issued with PKCE but no code_verifier was sent |
| 400 | invalid_grant | code_verifier 验证失败 | The verifier does not match the challenge — often a code_challenge_method typo |
| 400 | invalid_grant | 刷新令牌无效 | Unknown refresh token, or one issued to a different client |
| 400 | invalid_grant | 刷新令牌已被使用,所有令牌已撤销 | An already-rotated refresh token was presented — see below |
| 400 | invalid_grant | 刷新令牌已过期 | Past the 30-day window that started at first authorization |
invalid_client when you are sure the secret is right
Two causes account for nearly every case:
- The
=was not percent-encoded. Secrets and tokens end in=. If you assembled the body as a string instead of letting your HTTP library encode a parameter map, the value was truncated. - You sent HTTP Basic. Credentials go in the form body.
client_secret_basicis not supported and produces no distinguishing error.
刷新令牌已被使用,所有令牌已撤销
This is reuse detection. Presenting a refresh token that has already been rotated is treated as a replay, and the response revokes every access and refresh token for that user and application.
You reach it in three ways:
- You stored the refresh token from a response but the process died before the write landed, so you replayed the old one.
- Two workers refreshed concurrently. Serialize refreshes per user.
- The user revoked your application from their Nanako account settings, which marks the tokens revoked; your next honest refresh then looks like a replay.
In all three cases the recovery is the same: discard the stored tokens and send the user through the authorization flow again. Do not retry.
UserInfo
GET /oauth/userinfo.
| Status | error | error_description | Cause |
|---|---|---|---|
| 401 | invalid_token | — | No Authorization header, or it does not start with Bearer |
| 401 | invalid_token | — | Unknown or revoked token |
| 401 | invalid_token | token 已过期 | Past the 2-hour lifetime |
| 500 | server_error | — | The account behind the token no longer exists |
On the two undescribed 401s, refresh and retry once. If the refresh also
fails, re-authorize.
Revocation
POST /oauth/revoke.
| Status | error | Cause |
|---|---|---|
| 400 | invalid_request | client_id or token missing or empty — including when the body is JSON |
| 401 | invalid_client | Unknown client_id or a wrong client_secret |
| 200 | — | Everything else, including a token that does not exist, is not yours, or was sent without the required token_type_hint |
200 does not mean something was revoked. See
POST /oauth/revoke.
Not from OAuth
Two responses can reach you from the surrounding infrastructure rather than from the OAuth handlers:
| Status | Body | Meaning |
|---|---|---|
| 503 | {"error":"feature_disabled","feature":"developer"} | An operator has switched off the developer subsystem. It carries Retry-After: 300. Application management and the account UI's authorized-apps list are affected; the sign-in flow itself is not. |
| 413 | — | Request body over 110 MB. You should never see this on an OAuth endpoint. |
A retry policy that works
| Response | Retry? |
|---|---|
401 invalid_token on userinfo | Yes — refresh once, then retry |
invalid_grant on refresh | No — re-authorize |
invalid_grant on the code exchange | No — the code is spent; restart from /oauth/authorize |
invalid_client | No — this is a configuration error |
503 feature_disabled | Yes — after Retry-After |
5xx | Yes — with backoff, and only for idempotent reads |
Never retry a code exchange with the same code. Codes are single-use, and the second attempt fails whether or not the first succeeded.