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 和属性;必须补读的数据,再按明确的等待策略获取。
备注:内容仅供参考。
