Skip to content

Commit b07ba96

Browse files
Add exponential backoff retries for extension bundle download and reuse HttpClient (#11315)
* Implement exponential backoff retry * white space * update the test for fix * suppresing warning * introduce primary constructor * fix white space * Improve the UserAgent and AzureRef header. * Improve UserAgent and AzureRef header * fix user agent issue. * Fixing runtimeassemblies.json and minor code cleanup * Updating Microsoft.Extensions.Diagnostics resolution policy * reflect the comments * rollback bom --------- Co-authored-by: Fabio Cavalcante <[email protected]>
1 parent 3330cd7 commit b07ba96

13 files changed

+765
-63
lines changed

src/WebJobs.Script.WebHost/WebHostServiceCollectionExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Microsoft.Azure.WebJobs.Script.Configuration;
1515
using Microsoft.Azure.WebJobs.Script.Diagnostics;
1616
using Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks;
17+
using Microsoft.Azure.WebJobs.Script.ExtensionBundle;
1718
using Microsoft.Azure.WebJobs.Script.Grpc;
1819
using Microsoft.Azure.WebJobs.Script.Metrics;
1920
using Microsoft.Azure.WebJobs.Script.Middleware;
@@ -143,6 +144,8 @@ public static void AddWebJobsScriptHost(this IServiceCollection services, IConfi
143144
services.AddSingleton<IFunctionMetadataManager, FunctionMetadataManager>();
144145
services.AddSingleton<IWebFunctionsManager, WebFunctionsManager>();
145146
services.AddHttpClient();
147+
services.AddBundlesHttpClient();
148+
146149
services.AddSingleton<StartupContextProvider>();
147150
services.AddSingleton<IFileSystem>(_ => FileUtility.Instance);
148151
services.AddTransient<VirtualFileSystem>();

src/WebJobs.Script/Diagnostics/Extensions/ExtensionBundleLoggerExtension.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Net;
56
using System.Net.Http;
67
using Microsoft.Extensions.Logging;
78

@@ -83,6 +84,26 @@ internal static class ExtensionBundleLoggerExtension
8384
new EventId(111, nameof(MatchingBundleNotFound)),
8485
"Bundle version matching the {version} was not found");
8586

87+
private static readonly Action<ILogger, Uri, HttpStatusCode?, HttpRequestError?, string, string, string, Exception> _errorDownloadingExtensionBundleZipHttpRequest =
88+
LoggerMessage.Define<Uri, HttpStatusCode?, HttpRequestError?, string, string, string>(
89+
LogLevel.Error,
90+
new EventId(112, nameof(ErrorDownloadingExtensionBundleZipHttpRequest)),
91+
"Error downloading extension bundle zip content {zip}. Status Code:{statusCode}, RequestError:{requestError}, FilePath:{filePath}, Disk:{diskUsage}, AzureRef:{azureRef}");
92+
93+
// Logs unexpected (non-HttpRequestException) failures during bundle download. No HTTP status/request error data.
94+
private static readonly Action<ILogger, Uri, string, string, string, Exception> _errorDownloadingExtensionBundleZipUnexpected =
95+
LoggerMessage.Define<Uri, string, string, string>(
96+
LogLevel.Error,
97+
new EventId(113, nameof(ErrorDownloadingExtensionBundleZipUnexpected)),
98+
"Unexpected error downloading extension bundle Zip content {zip}. FilePath:{filePath}, Disk:{diskUsage}, AzureRef:{azureRef}");
99+
100+
// Logs IO-specific failures (e.g., disk full, device I/O issues) during bundle download.
101+
private static readonly Action<ILogger, Uri, string, string, string, Exception> _errorDownloadingExtensionBundleZipIO =
102+
LoggerMessage.Define<Uri, string, string, string>(
103+
LogLevel.Error,
104+
new EventId(114, nameof(ErrorDownloadingExtensionBundleZipIO)),
105+
"IO error downloading extension bundle Zip content {zip}. FilePath:{filePath}, Disk:{diskUsage}, AzureRef:{azureRef}");
106+
86107
public static void ContentProviderNotConfigured(this ILogger logger, string path)
87108
{
88109
_contentProviderNotConfigured(logger, path, null);
@@ -127,6 +148,23 @@ public static void ErrorDownloadingZip(this ILogger logger, Uri zipUri, HttpResp
127148
_errorDownloadingZip(logger, zip, statusCode, reasonPhrase, null);
128149
}
129150

151+
public static void ErrorDownloadingExtensionBundleZipHttpRequest(this ILogger logger, Exception ex, Uri zipUri, HttpStatusCode? statusCode, HttpRequestError? httpRequestError, string filePath, string diskUsage, string azureRef)
152+
{
153+
// Avoid premature ToString allocations; LoggerMessage will format only if enabled.
154+
_errorDownloadingExtensionBundleZipHttpRequest(logger, zipUri, statusCode, httpRequestError, filePath, diskUsage, azureRef, ex);
155+
}
156+
157+
public static void ErrorDownloadingExtensionBundleZipUnexpected(this ILogger logger, Exception ex, Uri zipUri, string filePath, string diskUsage, string azureRef)
158+
{
159+
// Generic non-HTTP failure (e.g. IO, disk full, zip extraction issues).
160+
_errorDownloadingExtensionBundleZipUnexpected(logger, zipUri, filePath, diskUsage, azureRef, ex);
161+
}
162+
163+
public static void ErrorDownloadingExtensionBundleZipIO(this ILogger logger, Exception ex, Uri zipUri, string filePath, string diskUsage, string azureRef)
164+
{
165+
_errorDownloadingExtensionBundleZipIO(logger, zipUri, filePath, diskUsage, azureRef, ex);
166+
}
167+
130168
public static void DownloadComplete(this ILogger logger, Uri zipUri, string filePath)
131169
{
132170
string zip = zipUri.ToString();
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Runtime.InteropServices;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
10+
using Polly;
11+
using Polly.Extensions.Http;
12+
13+
namespace Microsoft.Azure.WebJobs.Script.ExtensionBundle
14+
{
15+
public static class BundlesServiceCollectionExtensions
16+
{
17+
public static IServiceCollection AddBundlesHttpClient(this IServiceCollection services)
18+
{
19+
services.AddHttpClient(nameof(ExtensionBundleManager), client =>
20+
{
21+
var hostVersion = ScriptHost.Version;
22+
client.DefaultRequestHeaders.UserAgent.ParseAdd($"AzureFunctionsHost/{hostVersion}");
23+
})
24+
.AddPolicyHandler((sp, request) =>
25+
{
26+
var logger = sp.GetRequiredService<ILogger<ExtensionBundleManager>>();
27+
28+
var policyBuilder = HttpPolicyExtensions
29+
.HandleTransientHttpError()
30+
.OrResult(resp => resp.StatusCode == HttpStatusCode.TooManyRequests);
31+
32+
return policyBuilder.WaitAndRetryAsync(
33+
retryCount: 4,
34+
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
35+
onRetry: (outcome, delay, attempt, ctx) =>
36+
{
37+
var statusCode = outcome.Result?.StatusCode;
38+
var statusCodeDisplay = statusCode.HasValue ? ((int)statusCode.Value).ToString() : "None";
39+
string azureRef = null;
40+
outcome.Result?.TryGetAzureRef(out azureRef);
41+
logger.LogWarning(
42+
outcome.Exception,
43+
"Extension bundle download failure. Status: {StatusCode}, Attempt: {Attempt}, Uri: {Uri}, AzureRef: {AzureRef}. Retrying after {DelayMs}ms.",
44+
statusCodeDisplay,
45+
attempt,
46+
request?.RequestUri,
47+
azureRef,
48+
delay.TotalMilliseconds);
49+
});
50+
});
51+
return services;
52+
}
53+
}
54+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Net.Http;
7+
8+
namespace Microsoft.Azure.WebJobs.Script.ExtensionBundle
9+
{
10+
internal static class ExtensionBundleHttpExtensions
11+
{
12+
// The X-Azure-Ref header is added by Azure Front Door for requests that traverse Front Door to the origin.
13+
// Documentation: https://learn.microsoft.com/en-us/azure/frontdoor/front-door-http-headers-protocol#from-the-front-door-to-the-backend
14+
// We capture and log this value (sanitized + length bounded) with extension bundle download failures
15+
// to enable end-to-end correlation and root cause analysis on the Front Door side when diagnosing
16+
// CDN/edge or networking issues impacting bundle retrieval.
17+
internal const string AzureRefHeaderName = "X-Azure-Ref";
18+
19+
internal static bool TryGetAzureRef(this HttpResponseMessage response, out string azureRef)
20+
{
21+
ArgumentNullException.ThrowIfNull(response);
22+
23+
try
24+
{
25+
if (response.Headers is not null &&
26+
response.Headers.TryGetValues(AzureRefHeaderName, out var values))
27+
{
28+
azureRef = values.FirstOrDefault();
29+
return !string.IsNullOrEmpty(azureRef);
30+
}
31+
}
32+
catch (Exception)
33+
{
34+
}
35+
36+
azureRef = null;
37+
return false;
38+
}
39+
}
40+
}

src/WebJobs.Script/ExtensionBundle/ExtensionBundleManager.cs

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.IO.Compression;
88
using System.Linq;
99
using System.Net.Http;
10+
using System.Threading;
1011
using System.Threading.Tasks;
1112
using Microsoft.Azure.WebJobs.Script.Config;
1213
using Microsoft.Azure.WebJobs.Script.Configuration;
@@ -22,22 +23,25 @@ namespace Microsoft.Azure.WebJobs.Script.ExtensionBundle
2223
{
2324
public class ExtensionBundleManager : IExtensionBundleManager
2425
{
26+
private const string ExtensionBundleClientName = nameof(ExtensionBundleManager);
2527
private readonly IEnvironment _environment;
2628
private readonly ExtensionBundleOptions _options;
2729
private readonly FunctionsHostingConfigOptions _configOption;
2830
private readonly ILogger _logger;
2931
private readonly string _cdnUri;
3032
private readonly string _platformReleaseChannel;
33+
private readonly IHttpClientFactory _httpClientFactory;
3134
private string _extensionBundleVersion;
3235

33-
public ExtensionBundleManager(ExtensionBundleOptions options, IEnvironment environment, ILoggerFactory loggerFactory, FunctionsHostingConfigOptions configOption)
36+
public ExtensionBundleManager(ExtensionBundleOptions options, IEnvironment environment, ILoggerFactory loggerFactory, FunctionsHostingConfigOptions configOption, IHttpClientFactory httpClientFactory)
3437
{
3538
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
3639
_logger = loggerFactory.CreateLogger<ExtensionBundleManager>() ?? throw new ArgumentNullException(nameof(loggerFactory));
3740
_options = options ?? throw new ArgumentNullException(nameof(options));
3841
_configOption = configOption ?? throw new ArgumentNullException(nameof(configOption));
3942
_cdnUri = _environment.GetEnvironmentVariable(EnvironmentSettingNames.ExtensionBundleSourceUri) ?? ScriptConstants.ExtensionBundleDefaultSourceUri;
4043
_platformReleaseChannel = _environment.GetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel) ?? ScriptConstants.LatestPlatformChannelNameUpper;
44+
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
4145
}
4246

4347
public async Task<ExtensionBundleDetails> GetExtensionBundleDetails()
@@ -79,10 +83,8 @@ public bool IsLegacyExtensionBundle()
7983
/// <returns>Path of the extension bundle.</returns>
8084
public async Task<string> GetExtensionBundlePath()
8185
{
82-
using (var httpClient = new HttpClient())
83-
{
84-
return await GetBundle(httpClient);
85-
}
86+
var client = _httpClientFactory.CreateClient(ExtensionBundleClientName);
87+
return await GetBundle(client);
8688
}
8789

8890
/// <summary>
@@ -199,34 +201,103 @@ private string GetBundleFlavorForCurrentEnvironment()
199201
return ScriptConstants.ExtensionBundleForNonAppServiceEnvironment;
200202
}
201203

202-
private async Task<bool> TryDownloadZipFileAsync(Uri zipUri, string filePath, HttpClient httpClient)
204+
private async Task<bool> TryDownloadZipFileAsync(Uri zipUri, string filePath, HttpClient httpClient, CancellationToken cancellationToken = default)
203205
{
204-
_logger.DownloadingZip(zipUri, filePath);
205-
var response = await httpClient.GetAsync(zipUri);
206-
if (!response.IsSuccessStatusCode)
206+
string azureRef = string.Empty;
207+
try
208+
{
209+
_logger.DownloadingZip(zipUri, filePath);
210+
211+
using var response = await httpClient.GetAsync(zipUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
212+
213+
// Log AzureRef header if present (debug level to avoid noise in normal operations)
214+
response.TryGetAzureRef(out azureRef);
215+
216+
response.EnsureSuccessStatusCode();
217+
218+
using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true);
219+
await response.Content.CopyToAsync(fileStream, cancellationToken);
220+
await fileStream.FlushAsync(cancellationToken);
221+
222+
_logger.DownloadComplete(zipUri, filePath);
223+
224+
return true;
225+
}
226+
catch (HttpRequestException ex)
207227
{
208-
_logger.ErrorDownloadingZip(zipUri, response);
228+
var statusCode = ex.StatusCode;
229+
_logger.ErrorDownloadingExtensionBundleZipHttpRequest(
230+
ex,
231+
zipUri,
232+
statusCode,
233+
ex.HttpRequestError,
234+
filePath,
235+
GetDiskUsageSafe(filePath),
236+
azureRef);
209237
return false;
210238
}
211-
212-
using (var content = await response.Content.ReadAsStreamAsync())
213-
using (var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
239+
catch (IOException ex)
214240
{
215-
await content.CopyToAsync(stream);
241+
_logger.ErrorDownloadingExtensionBundleZipIO(
242+
ex,
243+
zipUri,
244+
filePath,
245+
GetDiskUsageSafe(filePath),
246+
azureRef);
247+
return false;
216248
}
249+
catch (Exception ex)
250+
{
251+
// Non-HttpRequestException path: log as unexpected without HTTP-specific fields.
252+
_logger.ErrorDownloadingExtensionBundleZipUnexpected(
253+
ex,
254+
zipUri,
255+
filePath,
256+
GetDiskUsageSafe(filePath),
257+
azureRef);
217258

218-
_logger.DownloadComplete(zipUri, filePath);
219-
return true;
259+
return false;
260+
}
220261
}
221262

222-
private async Task<string> GetLatestMatchingBundleVersionAsync()
263+
private string GetDiskUsageSafe(string path)
223264
{
224-
using (var httpClient = new HttpClient())
265+
try
266+
{
267+
var root = Path.GetPathRoot(path);
268+
if (string.IsNullOrEmpty(root))
269+
{
270+
return "error=RootPathNotFound";
271+
}
272+
273+
var di = new DriveInfo(root);
274+
const double BytesPerMB = 1024d * 1024d;
275+
double freeMb = di.AvailableFreeSpace / BytesPerMB;
276+
double totalMb = di.TotalSize / BytesPerMB;
277+
return $"free={freeMb:F2}MB total={totalMb:F2}MB";
278+
}
279+
catch (Exception ex)
225280
{
226-
return await GetLatestMatchingBundleVersionAsync(httpClient);
281+
return FormatDiskError(ex);
227282
}
228283
}
229284

285+
private static string FormatDiskError(Exception ex)
286+
{
287+
var msg = ex.Message?.Replace(Environment.NewLine, " ").Trim();
288+
if (!string.IsNullOrEmpty(msg) && msg.Length > 200)
289+
{
290+
msg = msg.Substring(0, 200) + "...";
291+
}
292+
return $"error={ex.GetType().Name}: {msg}";
293+
}
294+
295+
private async Task<string> GetLatestMatchingBundleVersionAsync()
296+
{
297+
var client = _httpClientFactory.CreateClient(ExtensionBundleClientName);
298+
return await GetLatestMatchingBundleVersionAsync(client);
299+
}
300+
230301
private async Task<string> GetLatestMatchingBundleVersionAsync(HttpClient httpClient)
231302
{
232303
var uri = new Uri($"{_cdnUri}/{ScriptConstants.ExtensionBundleDirectory}/{_options.Id}/{ScriptConstants.ExtensionBundleVersionIndexFile}");

src/WebJobs.Script/ScriptHostBuilderExtensions.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.ComponentModel;
77
using System.Diagnostics.Tracing;
8+
using System.Net.Http;
89
using System.Runtime.InteropServices;
910
using Microsoft.ApplicationInsights.Extensibility;
1011
using Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel;
@@ -137,7 +138,8 @@ public static IHostBuilder AddScriptHost(this IHostBuilder builder,
137138

138139
var extensionRequirementOptions = applicationOptions.RootServiceProvider.GetService<IOptions<ExtensionRequirementOptions>>();
139140

140-
ExtensionBundleManager bundleManager = new(extensionBundleOptions, SystemEnvironment.Instance, loggerFactory, configOption);
141+
var httpClientFactory = applicationOptions.RootServiceProvider.GetService<IHttpClientFactory>();
142+
var bundleManager = new ExtensionBundleManager(extensionBundleOptions, SystemEnvironment.Instance, loggerFactory, configOption, httpClientFactory);
141143
var metadataServiceManager = applicationOptions.RootServiceProvider.GetService<IFunctionMetadataManager>();
142144

143145
ScriptStartupTypeLocator locator = new(

src/WebJobs.Script/WebJobs.Script.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
5151
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" />
5252
<PackageReference Include="Microsoft.Extensions.Telemetry.Abstractions" Version="9.8.0" />
53+
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
54+
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.7" />
5355
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
5456
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
5557
<PackageReference Include="NuGet.ProjectModel" Version="5.11.6" />

0 commit comments

Comments
 (0)