PAYATHON 2026

Azure Function 连接 SharePoint Online 时为何出现 SSL 连接错误?

数据阿明

结论

现有信息还无法确定 SSL 连接失败的唯一原因。代码中有两处问题应优先修复:逐条删除时反复创建 HttpClient,以及没有及时释放请求和响应。这种写法会使连接池分散,给连接资源带来压力,也可能增加 Azure 出站网络的 SNAT 端口压力。

“处理约 1500 条后失败”只能作为排查线索,不能据此认定 SharePoint 存在 1500 条限制。还需要记录完整异常和出错步骤,确认失败发生在 SharePoint 请求、获取 token,还是 Azure SQL 连接期间。

读取代码的 token 重试还有一处明确错误:更新了 accessToken 变量,却没有更新发送请求时使用的 Authorization 请求头。

为什么现有代码容易在长时间运行后出问题?

读取阶段只创建了一个 HttpClient,但 UpsertWebExCallHistoryToSqlServer 的 foreach 循环对每条记录都会执行:

var httpClient = new HttpClient();
var response = await httpClient.SendAsync(request);

这些客户端通常各自管理连接池,整个删除流程无法有效复用同一个池。代码也没有显式释放 HttpRequestMessage 和 HttpResponseMessage。随着任务持续运行,这种使用方式值得优先排查。不过,仅凭这些代码,还不能认定连接资源耗尽就是根因。

日志也不够完整:

_logger.LogError($"Error: {ex.Message}");

这只记录了最外层消息。SSL 异常的内部异常可能包含证书验证失败、连接被重置、意外 EOF 或底层 socket 错误等线索。当前的 try 覆盖了读取、SQL 写入和删除等多个步骤,只看这条日志无法确认具体是哪个连接失败。

先记录完整异常和处理阶段

把异常对象传给日志方法,并在关键操作前记录阶段、页码或条目 ID。例如,删除阶段可以这样记录:

try
{
    _logger.LogInformation(
        "Deleting SharePoint item {ItemId}",
        callhistory.SharePointSourceID);

    // Send the deletion request here.
}
catch (Exception ex)
{
    _logger.LogError(
        ex,
        "SharePoint deletion failed for item {ItemId}",
        callhistory.SharePointSourceID);
    throw;
}

获取 token、读取分页和 SQL 写入也需要添加阶段日志。检查日志平台是否保留了异常堆栈与内部异常;不要把 access token 或数据库连接字符串写入日志。

原代码最开始的 GetJwtTokenUsingSystemManagedIdentity() 位于 try 外部。如果该调用失败,后面的 catch 不会捕获它,因此也应明确记录这次调用的异常。

拿到完整异常后,再按证据排查:

  • 如果是证书链、主机名或信任错误,检查实际请求域名、证书信任和是否存在 HTTPS 检查代理。
  • 如果是连接重置、EOF、连接超时或 socket 错误,结合故障时间检查出站连接、SNAT、网络设备和服务端连接情况。
  • 如果实际返回了 HTTP 状态码,则按状态码处理鉴权、授权或限流问题。

如果完整堆栈指向 SqlConnection.Open(),应转向检查 Azure SQL 的连接与证书配置,不能只修改 SharePoint 请求代码。

复用 HttpClient,并在每次请求上设置请求头

下面的示例适用于 Azure 中使用 system-assigned managed identity 的路径,需要项目已有相应的 Azure.Identity、Azure.Core 和 HTTP 命名空间。将 <tenant> 替换为实际租户域名前缀。

把客户端和凭据定义为类级别成员,替换原来的 token 方法,并增加统一发送方法:

private static readonly HttpClient SharePointHttpClient = new HttpClient();

private static readonly ManagedIdentityCredential SharePointCredential =
    new ManagedIdentityCredential();

private static readonly TokenRequestContext SharePointTokenContext =
    new TokenRequestContext(
        new[] { "https://<tenant>.sharepoint.com/.default" });

private static async Task<string> GetJwtTokenUsingSystemManagedIdentity()
{
    AccessToken token = await SharePointCredential.GetTokenAsync(
        SharePointTokenContext);

    return token.Token;
}

private async Task<HttpResponseMessage> SendSharePointAsync(
    HttpMethod method,
    string requestUrl,
    bool deleteItem = false)
{
    string accessToken = await GetJwtTokenUsingSystemManagedIdentity();

    using var request = new HttpRequestMessage(method, requestUrl);

    request.Headers.Authorization =
        new AuthenticationHeaderValue("Bearer", accessToken);
    request.Headers.Accept.ParseAdd("application/json;odata=verbose");

    if (deleteItem)
    {
        request.Headers.TryAddWithoutValidation("IF-MATCH", "*");
        request.Headers.TryAddWithoutValidation("X-HTTP-Method", "DELETE");
    }

    return await SharePointHttpClient.SendAsync(request);
}

复用 ManagedIdentityCredential 可以使用凭据实例的 token 缓存,也不必逐条创建新凭据。示例在每次发送前调用凭据获取 token,但这不意味着每次都会向身份服务发出新的网络请求。

请求头设置在各自的 HttpRequestMessage 上,便于共享客户端,也能避免读取请求意外带上删除请求头。不要在每个循环结束时释放共享的 HttpClient。

如果项目已经采用依赖注入,也可以通过 IHttpClientFactory 统一管理客户端的连接生命周期。上面的示例没有包含本地交互式登录;如果仍需 InteractiveBrowserCredential,也应在适当的作用域内复用凭据实例。

读取分页时,替换发送与解析部分:

using var response = await SendSharePointAsync(
    HttpMethod.Get,
    requestUrl);

response.EnsureSuccessStatusCode();

string content = await response.Content.ReadAsStringAsync();
using JsonDocument doc = JsonDocument.Parse(content);

var root = doc.RootElement.GetProperty("d");

// Continue processing results and __next as in the existing code.

这样可以避免把 401、403 或其他错误响应直接当成包含 d.results 的正常 JSON 解析。

在原有 SQL 写入循环中,保留 conn、sql 等现有定义,将写入后的删除部分替换为:

await conn.ExecuteAsync(sql, callhistory, null, 300);

string requestUrl =
    $"{apiBaseUrl}({callhistory.SharePointSourceID})";

using var response = await SendSharePointAsync(
    HttpMethod.Post,
    requestUrl,
    deleteItem: true);

response.EnsureSuccessStatusCode();

_logger.LogInformation(
    "Deleted SharePoint item {ItemId}; HTTP {StatusCode}",
    callhistory.SharePointSourceID,
    (int)response.StatusCode);

apiBaseUrl 可以提前在循环外构造。原来的 POST 配合 X-HTTP-Method: DELETE 可以继续使用;目前没有证据表明它是 SSL 错误的原因。IF-MATCH: * 会跳过特定版本的匹配检查,是否保留应取决于业务是否允许删除已被其他操作修改的条目。

修复 401 重试,但不要把它当成 SSL 修复

原代码先设置了:

httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

收到 401 后只更新:

accessToken = await GetJwtTokenUsingSystemManagedIdentity();

字符串变量改变后,已经设置的请求头不会自动更新,因此重试仍可能携带旧 token。新建请求时必须重新设置:

request.Headers.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

前面的统一发送方法已经按请求设置了该请求头。不过,再次调用 GetTokenAsync 不保证一定得到与上次不同的 token,也不应对持续的 401 无限重试。应检查 token 的目标资源是否与实际访问的 SharePoint 租户一致,以及 managed identity 是否获得相应的应用权限和站点访问授权。

SSL 握手发生在 HTTP 请求头发送之前。token 过期通常表现为 HTTP 鉴权响应,单纯刷新 token 不能解决证书或 TLS 握手故障。

重试与数据一致性也需要处理

原删除代码在收到任何 HTTP 响应后都会写入“删除成功”日志,即使响应是错误状态。必须先确认状态码成功,再记录成功。

对于可恢复故障,可以增加有次数上限的重试。收到 429 或 503 时,如果响应提供 Retry-After,应按其等待;其他暂时性网络错误可以使用带随机抖动的指数退避。证书验证错误、持续的 401 或 403,需要先修正配置,不应靠循环重试掩盖。

还要考虑“SQL 已写入,SharePoint 删除失败”的情况。这两个操作不是同一个数据库事务:

  1. 确认 SQL 写入已提交,再删除 SharePoint 条目。
  2. 让 SQL 写入可以安全重复执行。现有 MERGE 使用 ReportID 匹配,但仍需确认该字段是否满足业务唯一性要求,并配套数据库约束。
  3. 网络异常后,删除是否已经完成可能不明确。重跑时如果返回 404,应结合条目状态核对,不能简单认定首次删除从未执行。

最后,用超过原失败规模的数据量验证修改,并观察完整异常、连接情况和 HTTP 状态码。当前材料没有提供运行时版本、托管计划或内部异常,无法给出可靠的 TLS 强制配置;不要通过跳过证书验证来处理这个错误。

备注:内容仅供参考。