0. 一个第一次写 OAuth 刷新很容易漏掉的事
我第一版的 token 刷新逻辑大概是这样的:
取缓存的 access_token
→ 快过期了?
→ 拿 refresh_token 去换新的
→ 存进数据库
→ 更新缓存
看起来没问题。但它默认了一个前提:refresh token 是不变的。
很多 OAuth 实现不是这样。它们在每次刷新时把旧的 refresh token 作废,发一个新的——这叫 refresh token rotation。用途是检测泄露:如果一个旧的 refresh token 被重复使用,服务端就知道它被偷过。
这带来一个我在第一版里完全没考虑的场景:
刷新成功了,新 token 也拿到了,但存数据库失败了。
这时:
- 上游已经作废了旧的 refresh token;
- 我手上的内存里是新的(可用的);
- 数据库里还是旧的(已经失效的);
- 进程一重启,内存没了 → 我去数据库读那个失效的 refresh token → 刷新失败 → 账号废了。
这个 bug 不会立刻报错。它会在下一次进程重启之后才暴露,而那时你早已忘了之前那次存储失败。
这篇讲我怎么处理这一类"两份状态可能对不上"的问题,以及 OAuth 刷新这条链路上其他几个我踩到的点。
1. 五家平台,三种凭证形态
先交代背景。订阅账号支持五家平台,但它们的凭证形态只有三种:
const (
PlatformCodex Platform = "codex"
PlatformClaude Platform = "claude"
PlatformZhipu Platform = "zhipu" // GLM Coding Plan (static key)
PlatformMinimax Platform = "minimax" // MiniMax Coding Plan (static key)
PlatformKimi Platform = "kimi" // Kimi For Coding (OAuth refresh)
)
| 形态 | 平台 | 行为 |
|---|---|---|
| OAuth 可刷新 | codex、claude、kimi | 有 refresh token,临期主动换 |
| 静态密钥 | zhipu、minimax | 长期 API key,不过期、不刷新 |
| (未来) | — | — |
静态密钥那条路我单独做了一个实现,它的语义写得很清楚:
// Semantics:
// - GetAccessToken returns the access_token stored on the account (the
// vendor-issued Coding Plan key). The stored key is assumed non-expiring,
// so the provider neither caches nor refreshes; it reads from the
// AccountLookup on every call.
// - Refresh is a no-op: there is no refresh token to exchange. It returns
// nil so callers that call Refresh defensively (e.g. after a transient
// 401) do not mark the account broken for a refresh failure.
// - A persistent 401/403 from the upstream is the signal that the static
// key is actually invalid; that path is handled by the relay's
// runtime-blocker / SetSubscriptionAccountError chain keyed on the HTTP
// status, not by this provider.
三点都值得注意,尤其是 Refresh 返回 nil 这一条:
一个"防务性调用"不该导致账号被判死。 如果某个调用方在收到 401 之后习惯性地调一次 Refresh,而静态 provider 在这里返回错误,账号就会被标记成刷新失败——但静态密钥根本没有"刷新失败"这回事,它只是过期或不合法。这类错误的发生路径是"上游 401",该由运行时封禁处理,不该由凭证层处理。
而 GetAccessToken 每次都去查(不缓存)也不是偷懒:
// Invalidate is a no-op: there is no in-process token cache to evict. The
// provider re-reads the stored key on every GetAccessToken, so an operator
// rotating the key via the channel-service admin RPC takes effect on the
// next request without any cache invalidation here.
"不缓存"换来的是"改了就立刻生效"。 对不过期、成本极低的静态密钥,这笔交易划算。
2. 双保险:请求时刷新 + 后台预刷新
刷新的触发有两个来源,注释里叫"double-safety":
第一层:请求时刷新。 热路径上发现快过期就换。
const RefreshSkew = 3 * time.Minute
3 分钟的 skew——不是"过期了才换",而是"还剩 3 分钟就换"。 留这个余量是因为一次请求可能跑很久(流式响应几分钟很正常),如果等到过期才换,一个正在跑的流就会被中途掐断。
第二层:后台预刷新。
func DefaultRefreshTaskConfig() RefreshTaskConfig {
return RefreshTaskConfig{
Interval: 10 * time.Minute,
Lookahead: 24 * time.Hour,
MaxRetries: 3,
RetryBackoff: 2 * time.Second,
TempUnschedulableDuration: 10 * time.Minute,
}
}
每 10 分钟扫一遍,把24 小时内将要过期的账号提前换掉。
为什么要提前 24 小时?因为上游的 refresh 接口可能临时不可用。如果我等到还剩 3 分钟才去换,而那一刻上游正好抖动,这个账号就当场不可用了。提前一天开始尝试,我就有一整天的重试窗口。
这两层的关系不是"主备",而是"一个负责正确,一个负责平稳":
- 请求时刷新保证正确性——不管后台任务有没有跑,请求都不会因为 token 过期而失败;
- 后台预刷新保证平稳——避免所有账号的刷新都挤在请求路径上。
3. 核心问题:内存和持久化的两份状态
回到开头那个问题。现在的处理是这样的:
newCreds, err := b.refresher.refresh(ctx, refreshURL, creds.RefreshToken)
if err != nil {
return "", err
}
// Preserve the account id and client id from the stored record.
newCreds.AccountID = creds.AccountID
newCreds.ClientID = creds.ClientID
if newCreds.RefreshURL == "" {
newCreds.RefreshURL = creds.RefreshURL
}
if storeErr := b.lookup.Store(ctx, accountID, newCreds); storeErr != nil {
// domain-M1: persistence failed but we hold a fully-valid refreshed
// credential set, including the ROTATED refresh token. Cache the whole
// set in-process so (a) the current request succeeds with the new access
// token and (b) the next resolve reuses the rotated refresh token instead
// of the stale one the persistent lookup would return (which would brick
// the account once the access token expires). Do NOT return the store
// error: a typical caller does `if err != nil { return err }`, which would
// fail a request that has a perfectly good token. Report the persist
// failure via log/meter only.
b.cache.setCreds(accountID, newCreds)
logPersistFailure(accountID, storeErr)
return newCreds.AccessToken, nil
}
b.cache.setCreds(accountID, newCreds)
return newCreds.AccessToken, nil
这段注释有点长,但它记录了两个独立的决定。
3.1 决定一:存储失败不返回错误
注释里那句是关键:
Do NOT return the store error: a typical caller does
if err != nil { return err }, which would fail a request that has a perfectly good token.
我手上有一个完全可用的 access token,凭什么让这个请求失败?
如果返回错误,调用方的典型写法会把这次的用户请求判失败,而实际上这次请求本来可以正常完成。存储失败是运维问题,不该变成用户可见的故障。
所以只记日志:
func logPersistFailure(accountID int64, err error) {
log.Printf("credential: account %d token refreshed but persist failed (will retry on next resolve): %v", accountID, err)
}
(这个用 log.Printf 而不是项目统一的结构化 logger,是我想改但还没改的一处不一致。)
3.2 决定二:把整套凭证存进内存缓存,而不只是 access token
这是真正解决开头那个 bug 的地方。缓存里存的不只是一个字符串:
type cacheEntry struct {
accessToken string
expiresAt time.Time
// creds holds the full credential set (including a rotated refresh token)
// when set. It lets resolve reuse a refreshed refresh token in-process even
// if persistence (Store) failed, so the account does not brick on the next
// refresh (domain-M1). Nil for cache entries seeded from an access-token-only
// lookup.
creds *AccountCredentials
}
然后 resolve 在开头先看缓存里的完整凭证:
// domain-M1: prefer the in-process full credential cache when the access
// token is still valid. A previous refresh may have rotated the refresh
// token and failed to persist it (Store error); the persistent Lookup would
// then return the stale (now-invalid) refresh token and the account would
// brick on the next refresh. The cached creds carry the rotated token.
if cached, ok := b.cache.getCreds(accountID); ok && cached.AccessToken != "" && !staleExpiry(cached.ExpiresAt) && !force {
b.cache.set(accountID, cached.AccessToken, cached.ExpiresAt)
return cached.AccessToken, nil
}
关键在这行判断的顺序:先看内存里的完整凭证,然后才去持久化层查。
如果反过来(先查数据库拿到旧的 refresh token,再发现内存里有新的),那个旧的就已经被用来刷新了——而它已经失效。
这个设计把开头的场景变成了:
- 刷新成功,数据库存储失败;
- 内存里存下新的整套凭证(含轮转后的 refresh token);
- 后续请求命中内存缓存,用新的 refresh token 继续刷新;
- 账号在这个进程的生命周期内一直可用;
- 下次成功存储时,数据库被修正。
而只在"进程重启"这个前提下,第 3 步才会失效——那时数据库里还是旧的,账号需要重新绑定。
3.3 这个设计没解决的
我得说清楚它只是"缓解":
如果 Store 一直失败(比如数据库权限问题),那内存里的新 refresh token 永远存不回去。进程一重启,账号就废了。
我没有做的是:把存储失败重试做成一个持久化的队列。现在的"下次 resolve 时再试"依赖那个账号还被使用——如果一个账号存储失败之后没人再用它,就永远不会重试。
评论区的注释写的是 "will retry on next resolve",这是准确的:它依赖后续请求,而不是主动补偿。
4. 并发:同一账号不能同时刷两次
一个账号被很多并发请求同时使用,如果每个都发现 token 快过期,就会一起去打上游的 refresh 接口。
后果有两种,第二种更糟:
第一种:浪费。 几次并发的刷新请求,只有第一次是有意义的。
第二种:如果上游是 rotation 的,多个并发刷新可能互相作废。 第一个刷新让旧 refresh token 失效,第二个还在用旧的,于是失败——甚至可能触发上游的泄露检测。
所以每个账号一把锁:
type baseTokenProvider struct {
lookup AccountLookup
cache *tokenCache
refresher *refresher
refreshMu sync.Map // accountID int64 -> *sync.Mutex
// ...
}
func (b *baseTokenProvider) lockFor(accountID int64) *sync.Mutex {
v, _ := b.refreshMu.LoadOrStore(accountID, &sync.Mutex{})
return v.(*sync.Mutex)
}
func (b *baseTokenProvider) GetAccessToken(ctx context.Context, accountID int64) (string, error) {
if b.lookup == nil {
return "", ErrNotConfigured
}
if token, _, ok := b.cache.get(accountID); ok && !b.cache.stale(accountID) {
return token, nil
}
mu := b.lockFor(accountID)
mu.Lock()
defer mu.Unlock()
// Re-check after acquiring the lock: another goroutine may have refreshed.
if token, _, ok := b.cache.get(accountID); ok && !b.cache.stale(accountID) {
return token, nil
}
return b.resolve(ctx, accountID, false)
}
注意那个双重检查:
- 锁外先查一次缓存——绝大多数请求走这条快路径,完全不加锁;
- 拿锁;
- 锁内再查一次——因为在我等锁的这段时间,另一个 goroutine 可能已经刷完了。
第二次检查是必须的。 如果只有第一次,那么等锁的 N-1 个 goroutine 拿到锁之后会各自再刷一次,锁等于白加。
用 sync.Map 而不是 map + mutex,是因为锁的获取在热路径上(每个请求都会 LoadOrStore 一次),sync.Map 在"读多写少、key 集合稳定"的场景下比全局互斥锁好。
5. 后台任务的重试:两种失败不能同等对待
后台预刷新有重试,但不是所有错误都值得重试:
for attempt := 1; attempt <= t.maxRetries; attempt++ {
err := provider.Refresh(ctx, accountID)
if err == nil {
t.invalidate(provider, accountID)
// ...
return nil
}
lastErr = err
if isNonRetryableRefreshError(err) {
t.handleRefreshFailure(ctx, accountID, err, true)
return err
}
if attempt < t.maxRetries {
if aborted, abortErr := t.sleepBackoff(ctx, attempt); aborted {
return abortErr
}
}
}
t.handleRefreshFailure(ctx, accountID, lastErr, false)
return lastErr
func isNonRetryableRefreshError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrNoRefreshToken) || errors.Is(err, ErrAccountNotFound) {
return true
}
// domain-H2: the refresher now surfaces invalid_grant as the typed
// ErrInvalidGrant (wrapping ErrRefreshFailed), so errors.Is is the
// reliable check. The string fallbacks are retained for any caller that
// still constructs a plain error.
if errors.Is(err, ErrInvalidGrant) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "invalid_grant") || strings.Contains(msg, "invalid refresh")
}
三类错误直接放弃重试:
| 错误 | 含义 | 后果 |
|---|---|---|
ErrNoRefreshToken |
没有 refresh token | 要重新授权 |
ErrAccountNotFound |
账号不存在了 | 要人工处理 |
ErrInvalidGrant |
refresh token 被撤销/失效 | 要重新绑定 |
重试一个 invalid_grant 是纯粹的浪费,而且会让日志被同一类错误刷满。 更重要的是:这三种情况都不会自愈,只有人重新走一遍 OAuth 绑定才能解决。所以它们的处理路径是 OnRefreshNonRetryable,而不是"临时不可调度":
if nonRetryable {
if hookErr := t.hook.OnRefreshNonRetryable(ctx, accountID, reason); hookErr != nil {
// ...
}
return
}
until := time.Now().Add(t.tempBlock)
if hookErr := t.hook.OnRefreshRetryExhausted(ctx, accountID, until, reason); hookErr != nil {
// ...
}
两种失败对应两种 hook:
- 可重试但重试耗尽了 → 临时不可调度 10 分钟,之后 sweeper 会再试(第 9 篇讲的恢复策略);
- 不可重试 → 交给人工,进
manual策略。
这个区分直接决定了第 9 篇那个恢复 sweeper 会不会去动它。
5.1 invalid_grant 为什么要做成类型化错误
注释里的 "domain-H2" 记着这次改动的原因:
原来的做法是匹配错误字符串。 刷新失败时把上游的响应体拼进错误消息,然后 strings.Contains(msg, "invalid_grant")。
这个做法的问题很明显:上游改一下错误格式,判断就失效了。 而失效的后果是——一个永久失效的账号会被无限重试。
现在做成了类型化的 sentinel:
// ErrInvalidGrant is returned when the upstream token endpoint reports an
// invalid_grant error, meaning the refresh token is permanently revoked or
// expired and no amount of retrying will recover it. It wraps ErrRefreshFailed
// so callers checking for a generic refresh failure still match. Callers
// should use errors.Is(err, ErrInvalidGrant) to stop retrying the account
// (code-review 2026-07-30 domain-H2).
ErrInvalidGrant = fmt.Errorf("credential: %w: invalid grant", ErrRefreshFailed)
注意它是 %w 包着 ErrRefreshFailed 的。这样"泛化的刷新失败"判断和"具体的 invalid_grant"判断都能命中——两个都没丢。字符串匹配作为兜底还留着(注释里说明了),但主路径是类型。
6. 退避时间短得有点可疑
后台任务的重试退避是这样算的:
func (t *RefreshTask) sleepBackoff(ctx context.Context, attempt int) (aborted bool, abortErr error) {
d := t.backoff * time.Duration(1<<(attempt-1))
timer := time.NewTimer(d)
// ...
}
基准 2 秒,指数退避:2s、4s(MaxRetries = 3,所以只有两次退避)。
所以三次尝试加起来只覆盖大约 6 秒的窗口。
这个时间尺度对一个"上游 refresh 接口临抖动"的场景来说太短了。真正需要退避的场景(上游限流、网络分区)都是以分钟计的。
但我不打算简单把数字调大,因为这个任务的周期本身就是 10 分钟:
如果三次尝试在 6 秒内放弃,那它实际是在等下一个 10 分钟的周期再试。 换句话说,"重试"在这个任务里几乎不起作用——它真正依赖的是周期性的重扫。
这让我觉得当前的重试参数是一个没想清楚的设计:要么把退避拉到分钟级让它真的起作用,要么干脆去掉重试、只靠周期扫描。现在的形态是"看起来有重试,实际效果接近没有"。
我把它列在欠账里,因为改动它需要先想清楚"一次刷新失败之后,我应该多快再试"——而这取决于上游 refresh 接口的故障模式,我还没数据。
7. 一个我必须说清楚的未完成项
包注释里有一句话:
// Caching lives in Redis in a full deployment (key token:{platform}:{accountID});
// the MVP ships an in-process implementation and a Redis-backed implementation
// so the server can run with or without Redis.
"a Redis-backed implementation" 这句是错的。 我核实过,domain/upstream/credential 下只有内存实现(tokenCache 就是 map + RWMutex),没有 Redis 版本。
这不是文档过期,而是我在写注释的时候描述了一个我打算做但还没做的设计。这类注释比过期的注释更危险——它不是"曾经对过",而是"从来没对过"。
7.1 为什么没有 Redis 版本这件事挺严重
tokenCache 的注释里写了:
// tokenCache is an in-process cache of access tokens keyed by account ID. It
// exists so the hot path (GetAccessToken) does not hit the AccountLookup on
// every request. In a multi-instance deployment the Redis-backed provider
// should be used instead; this cache is the single-process fallback.
也就是说我自己知道多副本下需要共享缓存,但没做。
后果是这样的:如果有 N 个 relay 副本,每个副本独立发现"这个账号的 token 快过期了",于是N 个副本同时去刷新同一个账号。
而按第 3 节讲的,刷新是 rotation 的——并发刷新一个 rotation 型 refresh token,可能让某些副本拿到失效的 token。而每个副本的 refreshMu 只保护自己进程内的并发。
这和第 4 篇那个"并发槽在单进程内是内存版"是同一类问题:单进程内的保护看起来完整,但它保护不了多副本。
我没有做 Redis 版的直接原因是我还没把 relay 跑成多副本。但这不等于问题不存在——它是一个"扩副本时会立刻踩到"的定时炸弹,而且症状(账号随机失效)很难归因。
8. 现在的状态
已经能用的:
- 五家平台三种凭证形态,静态密钥走独立实现且
Refresh是 no-op - 请求时刷新(3 分钟 skew)+ 后台预刷新(10 分钟周期、24 小时 lookahead)双保险
- 每账号一把锁 + 双重检查,防止同账号并发刷新
- 刷新成功但存储失败时:不失败请求、把整套凭证(含轮转后的 refresh token)留在内存
ErrInvalidGrant类型化,包裹ErrRefreshFailed,两种判断都能命中- 三类不可重试错误走独立 hook,与"重试耗尽"区分
- 关停中断退避返回哨兵错误,不会把未完成的刷新误报成成功
还没解决的:
第一,凭证缓存只有内存版。 多副本下同账号会被并发刷新,而 rotation 型 refresh token 在并发下可能互相作废。这是目前最大的一个风险,而且症状难归因。
第二,存储失败没有主动补偿。 靠"下次 resolve 时再试",如果那个账号之后没人用,就永远不重试。
第三,重试退避时间尺度错配。 6 秒的总窗口对分钟级的上游抖动没有意义,实际靠 10 分钟周期扫描兜底。要么拉到分钟级,要么显式去掉重试。
第四,包注释里的 "Redis-backed implementation" 是错的。 需要删掉或者写清楚是待做。
第五,logPersistFailure 用的是标准库 log 而不是项目统一的结构化 logger。 凭证持久化失败是一个应该能被告警的运维事件,但它现在只是标准输出里的一行文本,没有指标。
9. 下一篇
下一篇讲订阅用量查询接口:用户怎么知道自己还剩多少额度、为什么这个接口要按 API Key 鉴权而不是登录态、以及在途请求(预扣了但还没结算)应该算不算进"已用"里。