PAYATHON 2026

Azure AD B2C 的 MS Graph API 启用用户测试为何需要延迟 10 秒?

排障赵师傅

从这段代码和测试现象看,最可能的解释是:更新已经成功,紧接着的查询却暂时读到了旧值。GetUserById() 实际使用的是 GET /users?$filter=id eq '...'。Microsoft 文档指出,用户列表查询对刚创建、更新或删除的用户可能存在复制延迟。List users 文档

建议先改用 GET /users/{id} 读取指定用户,再把固定等待 10 秒改为有截止时间的状态轮询。10 秒只是当前测试观察到的等待时间,接口并不保证在这段时间内就能读到新值。

为什么 await EnableUser() 之后仍会读到 false?

await PatchAsync(...) 会等待这次 HTTP 更新请求完成。后续查询能否读到新值,还取决于目录数据的复制进度。Microsoft Entra 采用最终一致性模型,成功写入后立即读取,可能暂时得到更新前的数据。Microsoft 关于最终一致性的说明

GetUserById() 内部调用的是:

await GetUsers($"id eq '{userId}'");

因此,虽然方法名叫 GetUserById(),实际发出的仍是用户列表查询,加上筛选条件也不会变成单用户读取接口:

GET /v1.0/users?$filter=id eq '{userId}'

此前测试一直通过,只能说明当时立即读取通常能拿到预期状态。仅凭这些代码,无法确定最近延迟增加的原因,也没有依据将其归因于 Azure AD B2C 停止向新客户开放。是否有服务端或客户端依赖变化,需要结合请求日志和变更记录确认。

先调整读取和更新方式

按 ID 读取用户,并显式选择 accountEnabled

在 GetUserById() 内部,将列表筛选替换为单用户请求。沿用题中的 SDK 调用风格,可以这样写:

User? user = await _graphClient.Users[userId].GetAsync(config =>
{
    config.QueryParameters.Select = new[]
    {
        "id",
        "accountEnabled",
        "displayName",
        "givenName",
        "surname"
    };
}).ConfigureAwait(false);

再用库中现有的转换逻辑,将 User 转成 IlgAdUser。示例只选择了部分字段;原测试还会比较其他属性,需要保留这些属性对应的 $select 项。

GET /users/{id} 是按 ID 获取用户的接口,但这并不保证强一致性,也不保证更新后立即读到新值。Get user 文档

accountEnabled 需要通过 $select 显式请求。检查 MakeUserAttributesList() 是否包含它,同时确认转换逻辑没有把属性未返回时的 null 当成 false。user 属性文档

启用用户时可以直接提交更新

调用方已经持有用户 ID,启用操作也不依赖用户的其他属性,可以省去更新前的查询:

public async Task EnableUser(string userId)
{
    if (string.IsNullOrWhiteSpace(userId))
        throw new ArgumentException("User ID is required.", nameof(userId));

    await _graphClient.Users[userId].PatchAsync(new User
    {
        AccountEnabled = true
    }).ConfigureAwait(false);
}

实际项目中可保留原有日志和异常封装。

PatchAsync() 没有返回用户对象是正常的:更新用户成功时,接口返回 204 No Content。Update user 文档

用状态轮询替换 LongDelay()

这项集成测试访问的是真实目录服务,需要验证在允许的时间内能否读到启用状态。读到预期状态后即可继续,超过等待窗口再判定失败。

下面的辅助方法调用调整后的 GetUserById(),并返回读到启用状态的那次查询结果:

private async Task<IlgAdUser> WaitForEnabledUser(
    string userId,
    TimeSpan timeout)
{
    var timer = System.Diagnostics.Stopwatch.StartNew();
    var delay = TimeSpan.FromMilliseconds(250);
    IlgAdUser? lastUser = null;

    while (timer.Elapsed < timeout)
    {
        lastUser = await _graphClient.GetUserById(userId);

        if (lastUser?.AccountEnabled == true)
            return lastUser;

        TimeSpan remaining = timeout - timer.Elapsed;
        if (remaining <= TimeSpan.Zero)
            break;

        await Task.Delay(delay < remaining ? delay : remaining);

        delay = TimeSpan.FromMilliseconds(
            Math.Min(delay.TotalMilliseconds * 2, 2000));
    }

    throw new TimeoutException(
        $"User '{userId}' was not observed as enabled within {timeout}. " +
        $"Last AccountEnabled: {lastUser?.AccountEnabled.ToString() ?? "unavailable"}");
}

测试中改为:

await _graphClient.EnableUser(b2CUser.Id);

IlgAdUser loaded = await WaitForEnabledUser(
    b2CUser.Id,
    TimeSpan.FromSeconds(30));

loaded.AccountEnabled.Should().Be(true);

loaded.Should().BeEquivalentTo(
    b2CUser,
    options => options
        .Excluding(u => u.AccountEnabled)
        .ComparingByMembers<IlgAdUser>());

这里的 30 秒是示例测试预算,需要按运行环境调整。它控制的是轮询窗口,单次 HTTP 请求仍需配置合适的超时,必要时让封装方法传递 CancellationToken。

排查时还要注意什么?

  • 保留失败状态码和内部异常。401、403 不能靠等待解决,上述轮询也不会吞掉这些异常。如果需要容忍刚创建用户时短暂出现的 404,应单独识别,不能把所有读取异常都转成 null。
  • 检查并行测试是否共用用户,避免一个测试启用用户时,另一个测试又禁用或删除它。
  • 对比原始 Graph 响应与 IlgAdUser 的属性值。如果原始响应已经是 true,转换结果仍为 false,应检查映射或自定义缓存。
  • 如果延迟持续异常,记录更新和读取的时间、状态码、request-id,以及每次返回的 accountEnabled,再结合 SDK、依赖和租户变更记录排查。

CreateLocalUser() 中创建成功后立即查询的步骤,也依赖读取时能否看到刚写入的数据。可以直接使用创建响应中已有的 ID 和属性;必须补读的数据,再按明确的等待策略获取。

备注:内容仅供参考。