OAuth 账号为什么会“突然失效”:Grimoire Router 的自愈机制是怎么做的

OAuth 账号为什么会“突然失效”:Grimoire Router 的自愈机制是怎么做的

很多人做 OAuth 上游接入时会有一个错觉:
只要能拿到刷新逻辑,账号掉线就能靠“重试”解决。
在 Grimoire Router,这个错觉被打破了。很多掉线不是刷新失败,而是错误被错误地写回了数据库或没有触发正确的自检。

image

这类故障的真实结构

我们在项目里遇到的主要失败路径是三类:

  1. access token invalidated
    上游返回 401 且 message 里带 invalidated,但 OAuthAccessTokenExpiresAtUtc 还在未来。
    如果只看到“还没过期”就不刷新,就会卡死在 401 循环。

  2. refresh_token_reused / refresh_token_invalidated
    刷新时的并发时序导致新旧 token 错位,下一次刷新用被消费的旧 token,直接把 token family 失效。

  3. relay 失败路径写回过宽
    在 401 或失败处理里把整个上游对象回写到 DB,可能把刚刚还没同步完成的字段覆盖回旧值,问题持续反复出现。

为什么它在多实例/多触发场景会更脆弱

OAuthTokenRefreshService、管理员手工刷新、连接测试、定时任务都可能在不同时间对同一账号做动作。
只要刷新路径里有这些问题之一,就会出现 race:

  • A 进入刷新拿旧 refresh token;
  • A 在内存里拿到新 token 但数据库还没落库;
  • B 紧接着又用旧 token 刷新,触发 reused;
  • A 之后再更新状态时又把旧 token 或旧时间“覆盖”回数据库。

所以,锁不是终点,持久化时机才是底层正确性。

代码层如何修复:先从服务端刷新链路说起

真实代码入口是 OAuthTokenRefreshService.RefreshSingleAccountAsync(...)。

关键一:每账号串行刷新 + 锁内重读(跨实例兜底)

// src/GrimoireRouter.Gateway/Routing/OAuthTokenRefreshService.cs
var semaphore = AccountLocks.GetOrAdd(account.Id, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(cancellationToken);
try
{
    // ...
}
finally
{
    semaphore.Release();
}

锁是“同一进程内单账号串行化”的第一层护栏。
更关键的是,它在拿到锁以后会做数据库重读,拿到别的实例/上一次执行已经落库的最新 token:

// src/GrimoireRouter.Gateway/Routing/OAuthTokenRefreshService.cs
var fresh = await dbContext.UpstreamAccounts
    .AsNoTracking()
    .Where(a => a.Id == account.Id)
    .Select(a => new { a.RefreshTokenEncrypted, a.OAuthAccessToken, a.OAuthAccessTokenExpiresAtUtc })
    .FirstOrDefaultAsync(cancellationToken);

if (fresh.OAuthAccessTokenExpiresAtUtc.HasValue
    && fresh.OAuthAccessTokenExpiresAtUtc.Value - DateTimeOffset.UtcNow > FreshTokenSkipWindow
    && !string.IsNullOrWhiteSpace(fresh.OAuthAccessToken))
{
    account.OAuthAccessToken = fresh.OAuthAccessToken;
    account.OAuthAccessTokenExpiresAtUtc = fresh.OAuthAccessTokenExpiresAtUtc;
    return;
}

这段逻辑能把“另一个实例刚刷新过”的事实带入当前分支,减少重复刷新。

关键二:刷新成功后,必须在锁内立即持久化

不是先更新对象再等外部 SaveChangesAsync(),而是在锁内立刻调用:

// src/GrimoireRouter.Gateway/Routing/OAuthTokenRefreshService.cs
if (dbContext is not null)
{
    await SaveRotatedTokenWithRetryAsync(dbContext, cancellationToken);
}

它的定义很短,但意义非常大:
如果新的 refresh_token 在锁内不入库,下一次持锁方又会读到旧 token,复用窗口继续复发。

private static async Task SaveRotatedTokenWithRetryAsync(GrimoireRouterDbContext dbContext, CancellationToken ct)
{
    const int maxSaveAttempts = 3;
    for (var attempt = 1; attempt <= maxSaveAttempts; attempt++)
    {
        try
        {
            await dbContext.SaveChangesAsync(ct);
            return;
        }
        catch (Exception) when (attempt < maxSaveAttempts)
        {
            await Task.Delay(RefreshRetryDelay, ct);
        }
    }
}

关键三:错误分类必须准确,不要把一切都当“普通失败”

// src/GrimoireRouter.Gateway/Routing/OAuthTokenRefreshService.cs
public static bool IsRefreshTokenReused(OpenAiOAuthException exception)

项目里覆盖了:

  • refresh_token_reused
  • refresh token has already been used
  • refresh_token_invalidated
  • refresh token has been invalidated

这样可以避免“刷新失败”与“家族失效必须重 OAuth”的语义被混淆,前端提示和处理策略不同。

关键一:relay 端自愈触发器

即使刷新链路正确,也可能出现上游将 access token 单独吊销。
此时仅靠后端刷新流程不会触发,因为它看上去“还没到期”。
所以 ResponsesRelayService.MarkFailureAsync 引入了判定:

// src/GrimoireRouter.Gateway/Responses/ResponsesRelayService.cs
if (IsOAuthAccessTokenInvalidated(upstreamAccount, httpStatusCode, message))
{
    upstreamAccount.OAuthAccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1);
}

IsOAuthAccessTokenInvalidated(...) 的要求是:

  • 账号类型是 OAuthSession;
  • 状态码是 401;
  • message 包含 invalidated 且语义接近“authentication token invalidated”。

这样做会把 token 标记为过期,下一次 RefreshSingleAccountAsync 就会优先处理这个账号。

关键二:失败路径只写必要列,避免“全量覆写风暴”

你会看到 MarkFailureAsync 只修改:

  • circuit state(重试窗口)
  • 上游运行时诊断字段
  • OAuthAccessTokenExpiresAtUtc(仅在自愈条件命中时)

不把 OAuthAccessToken、RefreshTokenEncrypted 在错误路径整量更新,是故意的。
这是避免把别的刷新分支落库后的新 token 覆盖回旧值的核心策略之一。

这篇文章里的最小落地步骤(按时间执行)

  1. 给 OAuth token 刷新服务加 SemaphoreSlim 单账号锁(AccountLocks)。
  2. 刷新时加数据库 re-read(AsNoTracking);
  3. 刷新成功后在锁内 SaveRotatedTokenWithRetryAsync;
  4. relay 失败判断 401 invalidated 时设置 access token 过期时间;
  5. 错误码分类加入 refresh_token_invalidated;
  6. 前端保留手工 OAuth Access Token 页面用于运维应急(不替代自动自愈)。
后端定时扫描 -> RefreshSingleAccountAsync -> SaveRotatedTokenWithRetryAsync
客户端 relay 401 invalidated -> MarkFailureAsync -> OAuthAccessTokenExpiresAtUtc = now-1m
下一轮 refresh 轮询 -> 扫到过期窗口 -> 重新换 token

一句话总结

OAuth 自愈不是“多加几条重试”,而是三件事齐套:
锁内并发控制 + 刷新成功立即落库 + relay 错误语义映射成自愈动作。
缺一不可时,系统看起来能恢复,实际上容易反复掉线。

posted @ 2026-06-22 00:01  初久的私房菜  阅读(13)  评论(0)    收藏  举报
作者:初久的私房菜
好好学习,天天向上
返回顶部小火箭
好友榜:
如果愿意,把你的博客地址放这里
张弛:https://blog.zhangchi.fun/