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 删除失败”的情况。这两个操作不是同一个数据库事务:
- 确认 SQL 写入已提交,再删除 SharePoint 条目。
- 让 SQL 写入可以安全重复执行。现有
MERGE使用ReportID匹配,但仍需确认该字段是否满足业务唯一性要求,并配套数据库约束。 - 网络异常后,删除是否已经完成可能不明确。重跑时如果返回 404,应结合条目状态核对,不能简单认定首次删除从未执行。
最后,用超过原失败规模的数据量验证修改,并观察完整异常、连接情况和 HTTP 状态码。当前材料没有提供运行时版本、托管计划或内部异常,无法给出可靠的 TLS 强制配置;不要通过跳过证书验证来处理这个错误。
备注:内容仅供参考。
