From 874394db76dbaffbd0a0c8ca9285a5d0f1439550 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 19 May 2026 12:31:15 +0100 Subject: [PATCH 1/8] upgrade to .net 10 --- .../CPS.ComplexCases.API.HttpTelemetry.csproj | 2 +- ....ComplexCases.API.Integration.Tests.csproj | 8 +- .../CPS.ComplexCases.API.Tests.csproj | 2 +- .../CPS.ComplexCases.API.csproj | 12 +- .../Extensions/IServiceCollectionExtension.cs | 36 +++--- .../CPS.ComplexCases.ActivityLog.Tests.csproj | 2 +- .../CPS.ComplexCases.ActivityLog.csproj | 3 +- .../CPS.ComplexCases.Common.Tests.csproj | 2 +- .../CPS.ComplexCases.Common.csproj | 2 +- .../CPS.ComplexCases.DDEI.Tests.csproj | 2 +- .../CPS.ComplexCases.DDEI.WireMock.csproj | 2 +- .../CPS.ComplexCases.DDEI.csproj | 6 +- .../Extensions/IServiceCollectionExtension.cs | 42 ++++--- .../CPS.ComplexCases.Data.csproj | 11 +- .../CPS.ComplexCases.Egress.Tests.csproj | 2 +- .../CPS.ComplexCases.Egress.WireMock.csproj | 2 +- .../CPS.ComplexCases.Egress.csproj | 5 +- .../Extensions/IServiceCollectionExtension.cs | 52 ++++----- ...ComplexCases.FileTransfer.API.Tests.csproj | 2 +- .../CPS.ComplexCases.FileTransfer.API.csproj | 10 +- .../Orchestration/BatchOrchestratorBase.cs | 4 +- .../Orchestration/TransferOrchestrator.cs | 6 +- .../CPS.ComplexCases.NetApp.Tests.csproj | 2 +- .../Integration/NetAppClientTests.cs | 5 +- .../Integration/NetAppStorageClientTests.cs | 1 - .../Unit/Factories/S3ClientFactoryTests.cs | 2 +- .../CPS.ComplexCases.NetApp.WireMock.csproj | 14 +-- .../CPS.ComplexCases.NetApp.csproj | 5 +- .../Client/NetAppClient.cs | 108 ++++++++++-------- .../Extensions/IServiceCollectionExtension.cs | 50 ++++---- .../Factories/NetAppCertFactory.cs | 6 +- .../Factories/S3ClientFactory.cs | 7 +- .../CPS.ComplexCases.WireMock.Core.csproj | 2 +- .../CPS.ComplexCases.WireMock.csproj | 2 +- .../Cps.ComplexCases.Data.Tests.csproj | 4 +- .../backend/backend-build-and-deploy.yml | 8 +- .../backend/backend-pr-build-and-test.yml | 4 +- .../backend/backend-pr-integration-tests.yml | 4 +- .../templates/dotnet-build-steps.yml | 2 +- .../templates/fa-config-steps.yml | 2 +- .../templates/fa-deploy-steps.yml | 2 +- devops-pipelines/ui/ui-pr-build-and-test.yml | 2 +- scripts/build-and-test-backend-local.ps1 | 2 +- scripts/build-and-test-local.ps1 | 2 +- 44 files changed, 228 insertions(+), 223 deletions(-) diff --git a/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj b/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj index 0f6dcf941..8bfd4e376 100644 --- a/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj +++ b/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj @@ -4,7 +4,7 @@ 1.0.41 - net6.0 + net10.0 enable enable diff --git a/backend/CPS.ComplexCases.API.Integration.Tests/CPS.ComplexCases.API.Integration.Tests.csproj b/backend/CPS.ComplexCases.API.Integration.Tests/CPS.ComplexCases.API.Integration.Tests.csproj index b439aa589..2a3f73590 100644 --- a/backend/CPS.ComplexCases.API.Integration.Tests/CPS.ComplexCases.API.Integration.Tests.csproj +++ b/backend/CPS.ComplexCases.API.Integration.Tests/CPS.ComplexCases.API.Integration.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable false @@ -23,9 +23,9 @@ - - - + + + diff --git a/backend/CPS.ComplexCases.API.Tests/CPS.ComplexCases.API.Tests.csproj b/backend/CPS.ComplexCases.API.Tests/CPS.ComplexCases.API.Tests.csproj index 49aba6c76..ef6ec5ffa 100644 --- a/backend/CPS.ComplexCases.API.Tests/CPS.ComplexCases.API.Tests.csproj +++ b/backend/CPS.ComplexCases.API.Tests/CPS.ComplexCases.API.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable diff --git a/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj b/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj index 09915eda4..f2ec9d7d3 100644 --- a/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj +++ b/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj @@ -1,6 +1,6 @@  - net8.0 + net10.0 v4 Exe enable @@ -18,14 +18,14 @@ - + - + - - + - + + diff --git a/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs index 043666bdd..da9fad63c 100644 --- a/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs @@ -3,9 +3,9 @@ using CPS.ComplexCases.API.Domain.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Options; using Polly; -using Polly.Contrib.WaitAndRetry; namespace CPS.ComplexCases.API.Extensions; @@ -24,29 +24,23 @@ public static void AddFileTransferClient(this IServiceCollection services, IConf } client.BaseAddress = new Uri(options.BaseUrl); }) - .AddPolicyHandler((serviceProvider, request) => + .AddResilienceHandler("file-transfer-retry", (pipeline, context) => { - var options = serviceProvider.GetRequiredService>().Value; - return CreateRetryPolicy(options); + var options = context.ServiceProvider.GetRequiredService>().Value; + var retryAttempts = options.RetryAttempts > 0 ? options.RetryAttempts : 2; + var firstRetryDelaySeconds = options.FirstRetryDelaySeconds > 0 ? options.FirstRetryDelaySeconds : 1; + + pipeline.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = retryAttempts, + Delay = TimeSpan.FromSeconds(firstRetryDelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = static args => + ValueTask.FromResult(args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError) + }); }); services.AddTransient(); } - - private static IAsyncPolicy CreateRetryPolicy(FileTransferApiOptions options) - { - var retryAttempts = options.RetryAttempts > 0 ? options.RetryAttempts : 2; - var firstRetryDelaySeconds = options.FirstRetryDelaySeconds > 0 ? options.FirstRetryDelaySeconds : 1; - - return Policy - .HandleResult(ShouldRetry) - .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2( - medianFirstRetryDelay: TimeSpan.FromSeconds(firstRetryDelaySeconds), - retryCount: retryAttempts)); - } - - private static bool ShouldRetry(HttpResponseMessage response) - { - return response.StatusCode >= HttpStatusCode.InternalServerError; - } } \ No newline at end of file diff --git a/backend/CPS.ComplexCases.ActivityLog.Tests/CPS.ComplexCases.ActivityLog.Tests.csproj b/backend/CPS.ComplexCases.ActivityLog.Tests/CPS.ComplexCases.ActivityLog.Tests.csproj index 49520cb5d..d99bd6e9f 100644 --- a/backend/CPS.ComplexCases.ActivityLog.Tests/CPS.ComplexCases.ActivityLog.Tests.csproj +++ b/backend/CPS.ComplexCases.ActivityLog.Tests/CPS.ComplexCases.ActivityLog.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable true diff --git a/backend/CPS.ComplexCases.ActivityLog/CPS.ComplexCases.ActivityLog.csproj b/backend/CPS.ComplexCases.ActivityLog/CPS.ComplexCases.ActivityLog.csproj index 1fca1aba9..97f259d6e 100644 --- a/backend/CPS.ComplexCases.ActivityLog/CPS.ComplexCases.ActivityLog.csproj +++ b/backend/CPS.ComplexCases.ActivityLog/CPS.ComplexCases.ActivityLog.csproj @@ -2,7 +2,7 @@ Library - net8.0 + net10.0 enable enable true @@ -10,7 +10,6 @@ - diff --git a/backend/CPS.ComplexCases.Common.Tests/CPS.ComplexCases.Common.Tests.csproj b/backend/CPS.ComplexCases.Common.Tests/CPS.ComplexCases.Common.Tests.csproj index 1e5763b4b..b9d7cfe94 100644 --- a/backend/CPS.ComplexCases.Common.Tests/CPS.ComplexCases.Common.Tests.csproj +++ b/backend/CPS.ComplexCases.Common.Tests/CPS.ComplexCases.Common.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable false diff --git a/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj b/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj index 600406218..d3287877c 100644 --- a/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj +++ b/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj @@ -2,7 +2,7 @@ Library - net8.0 + net10.0 enable enable true diff --git a/backend/CPS.ComplexCases.DDEI.Tests/CPS.ComplexCases.DDEI.Tests.csproj b/backend/CPS.ComplexCases.DDEI.Tests/CPS.ComplexCases.DDEI.Tests.csproj index 63f03498b..cf4bcce20 100644 --- a/backend/CPS.ComplexCases.DDEI.Tests/CPS.ComplexCases.DDEI.Tests.csproj +++ b/backend/CPS.ComplexCases.DDEI.Tests/CPS.ComplexCases.DDEI.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable diff --git a/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj b/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj index 6f074ef42..8072fbba9 100644 --- a/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj +++ b/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable true diff --git a/backend/CPS.ComplexCases.DDEI/CPS.ComplexCases.DDEI.csproj b/backend/CPS.ComplexCases.DDEI/CPS.ComplexCases.DDEI.csproj index 24ba4806f..6b756993c 100644 --- a/backend/CPS.ComplexCases.DDEI/CPS.ComplexCases.DDEI.csproj +++ b/backend/CPS.ComplexCases.DDEI/CPS.ComplexCases.DDEI.csproj @@ -2,7 +2,7 @@ Library - net8.0 + net10.0 enable enable true @@ -11,9 +11,7 @@ - - - + diff --git a/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs index e7651bd3a..5f4df625d 100644 --- a/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs @@ -6,10 +6,9 @@ using CPS.ComplexCases.DDEI.Mappers; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Options; using Polly; -using Polly.Contrib.WaitAndRetry; -using Polly.Retry; namespace CPS.ComplexCases.DDEI.Extensions; @@ -24,7 +23,7 @@ public static void AddDdeiClient(this IServiceCollection services, IConfiguratio services.AddTransient(); services.AddHttpClient(AddDdeiClient) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddPolicyHandler(GetRetryPolicy()); + .AddResilienceHandler("ddei-retry", ConfigureResiliencePipeline); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -44,24 +43,31 @@ internal static void AddDdeiClient(IServiceProvider configuration, HttpClient cl } } - private static AsyncRetryPolicy GetRetryPolicy() + private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder pipeline) { - // https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly#add-a-jitter-strategy-to-the-retry-policy - var delay = Backoff.DecorrelatedJitterBackoffV2( - medianFirstRetryDelay: TimeSpan.FromSeconds(FirstRetryDelaySeconds), - retryCount: RetryAttempts); + // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience + pipeline.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = RetryAttempts, + Delay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = static args => + { + if (args.Outcome.Exception is HttpRequestException) + return ValueTask.FromResult(true); - static bool responseStatusCodePredicate(HttpResponseMessage response) => - response.StatusCode >= HttpStatusCode.InternalServerError - || response.StatusCode == HttpStatusCode.NotFound; + if (args.Outcome.Result is null) + return ValueTask.FromResult(false); - static bool methodPredicate(HttpResponseMessage response) => - response.RequestMessage?.Method != HttpMethod.Post - && response.RequestMessage?.Method != HttpMethod.Put; + var response = args.Outcome.Result; + var isRetryableStatus = response.StatusCode >= HttpStatusCode.InternalServerError + || response.StatusCode == HttpStatusCode.NotFound; + var isRetryableMethod = response.RequestMessage?.Method != HttpMethod.Post + && response.RequestMessage?.Method != HttpMethod.Put; - return Policy - .Handle() - .OrResult(r => responseStatusCodePredicate(r) && methodPredicate(r)) - .WaitAndRetryAsync(delay); + return ValueTask.FromResult(isRetryableStatus && isRetryableMethod); + } + }); } } \ No newline at end of file diff --git a/backend/CPS.ComplexCases.Data/CPS.ComplexCases.Data.csproj b/backend/CPS.ComplexCases.Data/CPS.ComplexCases.Data.csproj index db354c750..4a06ed09e 100644 --- a/backend/CPS.ComplexCases.Data/CPS.ComplexCases.Data.csproj +++ b/backend/CPS.ComplexCases.Data/CPS.ComplexCases.Data.csproj @@ -1,7 +1,7 @@  Library - net8.0 + net10.0 enable enable true @@ -12,15 +12,14 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - + diff --git a/backend/CPS.ComplexCases.Egress.Tests/CPS.ComplexCases.Egress.Tests.csproj b/backend/CPS.ComplexCases.Egress.Tests/CPS.ComplexCases.Egress.Tests.csproj index 3cdbf7a7c..6c2985262 100644 --- a/backend/CPS.ComplexCases.Egress.Tests/CPS.ComplexCases.Egress.Tests.csproj +++ b/backend/CPS.ComplexCases.Egress.Tests/CPS.ComplexCases.Egress.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable diff --git a/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj b/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj index 1231cde0b..762c02179 100644 --- a/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj +++ b/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj @@ -2,7 +2,7 @@ Library - net8.0 + net10.0 enable enable true diff --git a/backend/CPS.ComplexCases.Egress/CPS.ComplexCases.Egress.csproj b/backend/CPS.ComplexCases.Egress/CPS.ComplexCases.Egress.csproj index a6c1d841e..651415b4f 100644 --- a/backend/CPS.ComplexCases.Egress/CPS.ComplexCases.Egress.csproj +++ b/backend/CPS.ComplexCases.Egress/CPS.ComplexCases.Egress.csproj @@ -2,7 +2,7 @@ Library - net8.0 + net10.0 enable enable true @@ -11,8 +11,7 @@ - - + diff --git a/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs index f3f715a24..a950d709b 100644 --- a/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs @@ -4,9 +4,8 @@ using CPS.ComplexCases.Egress.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; using Polly; -using Polly.Contrib.WaitAndRetry; -using Polly.Wrap; namespace CPS.ComplexCases.Egress.Extensions; @@ -30,7 +29,7 @@ public static void AddEgressClient(this IServiceCollection services, IConfigurat client.Timeout = TimeSpan.FromMinutes(10); }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddPolicyHandler(GetRetryPolicy()); + .AddResilienceHandler("egress-retry", ConfigureResiliencePipeline); services.AddHttpClient(client => { @@ -43,35 +42,34 @@ public static void AddEgressClient(this IServiceCollection services, IConfigurat client.Timeout = TimeSpan.FromMinutes(10); }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddPolicyHandler(GetRetryPolicy()); + .AddResilienceHandler("egress-storage-retry", ConfigureResiliencePipeline); } - private static AsyncPolicyWrap GetRetryPolicy() + private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder pipeline) { + // Egress API has rate limiting so limit concurrency (equivalent to Polly v7 bulkhead) + pipeline.AddConcurrencyLimiter(permitLimit: 30, queueLimit: int.MaxValue); - // Egress API has rate limiting policy so we need to respect that and handle 429s - var bulkheadPolicy = Policy.BulkheadAsync( - maxParallelization: 30, - maxQueuingActions: int.MaxValue - ); - - // https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly#add-a-jitter-strategy-to-the-retry-policy - var delay = Backoff.DecorrelatedJitterBackoffV2( - medianFirstRetryDelay: TimeSpan.FromSeconds(FirstRetryDelaySeconds), - retryCount: RetryAttempts); - - static bool responseStatusCodePredicate(HttpResponseMessage response) => - response.StatusCode >= HttpStatusCode.InternalServerError - || response.StatusCode == HttpStatusCode.TooManyRequests; - - static bool methodPredicate(HttpResponseMessage response) => - response.RequestMessage?.Method != HttpMethod.Post - && response.RequestMessage?.Method != HttpMethod.Put; + // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience + pipeline.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = RetryAttempts, + Delay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = static args => + { + if (args.Outcome.Result is null) + return ValueTask.FromResult(false); - var retryPolicy = Policy - .HandleResult(r => responseStatusCodePredicate(r) && methodPredicate(r)) - .WaitAndRetryAsync(delay); + var response = args.Outcome.Result; + var isRetryableStatus = response.StatusCode >= HttpStatusCode.InternalServerError + || response.StatusCode == HttpStatusCode.TooManyRequests; + var isRetryableMethod = response.RequestMessage?.Method != HttpMethod.Post + && response.RequestMessage?.Method != HttpMethod.Put; - return Policy.WrapAsync(bulkheadPolicy, retryPolicy); + return ValueTask.FromResult(isRetryableStatus && isRetryableMethod); + } + }); } } \ No newline at end of file diff --git a/backend/CPS.ComplexCases.FileTransfer.API.Tests/CPS.ComplexCases.FileTransfer.API.Tests.csproj b/backend/CPS.ComplexCases.FileTransfer.API.Tests/CPS.ComplexCases.FileTransfer.API.Tests.csproj index 4c0c91a41..6f0b26476 100644 --- a/backend/CPS.ComplexCases.FileTransfer.API.Tests/CPS.ComplexCases.FileTransfer.API.Tests.csproj +++ b/backend/CPS.ComplexCases.FileTransfer.API.Tests/CPS.ComplexCases.FileTransfer.API.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable false diff --git a/backend/CPS.ComplexCases.FileTransfer.API/CPS.ComplexCases.FileTransfer.API.csproj b/backend/CPS.ComplexCases.FileTransfer.API/CPS.ComplexCases.FileTransfer.API.csproj index 5741316c7..9c1feb36b 100644 --- a/backend/CPS.ComplexCases.FileTransfer.API/CPS.ComplexCases.FileTransfer.API.csproj +++ b/backend/CPS.ComplexCases.FileTransfer.API/CPS.ComplexCases.FileTransfer.API.csproj @@ -1,6 +1,6 @@ - net8.0 + net10.0 v4 Exe enable @@ -12,15 +12,15 @@ - + - + - - + + diff --git a/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/BatchOrchestratorBase.cs b/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/BatchOrchestratorBase.cs index 6b4ff07b4..ca3329a32 100644 --- a/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/BatchOrchestratorBase.cs +++ b/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/BatchOrchestratorBase.cs @@ -65,7 +65,7 @@ protected async Task RunOrchestratorAsync(TaskOrchestrationContext context, stri TotalFiles = input.Files.Count, BucketName = input.BucketName, CaseId = input.CaseId, - OrchestrationStartTime = DateTime.UtcNow + OrchestrationStartTime = context.CurrentUtcDateTime }; var completedSuccessfully = false; @@ -114,7 +114,7 @@ await context.CallActivityAsync( input.ManageMaterialsOperationId); telemetryEvent.IsSuccessful = completedSuccessfully && telemetryEvent.TotalFilesFailed == 0; - telemetryEvent.OrchestrationEndTime = DateTime.UtcNow; + telemetryEvent.OrchestrationEndTime = context.CurrentUtcDateTime; _telemetryClient.TrackEvent(telemetryEvent); } } diff --git a/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/TransferOrchestrator.cs b/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/TransferOrchestrator.cs index 010f01afd..1ab252150 100644 --- a/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/TransferOrchestrator.cs +++ b/backend/CPS.ComplexCases.FileTransfer.API/Durable/Orchestration/TransferOrchestrator.cs @@ -45,7 +45,7 @@ public async Task RunOrchestrator( TotalFiles = input.SourcePaths.Count, BucketName = input.BucketName, CaseId = input.CaseId, - OrchestrationStartTime = DateTime.UtcNow + OrchestrationStartTime = context.CurrentUtcDateTime }; try @@ -276,7 +276,7 @@ await context.CallActivityAsync( }); transferOrchestrationEvent.IsSuccessful = transferOrchestrationEvent.TotalFilesFailed == 0; - transferOrchestrationEvent.OrchestrationEndTime = DateTime.UtcNow; + transferOrchestrationEvent.OrchestrationEndTime = context.CurrentUtcDateTime; _telemetryClient.TrackEvent(transferOrchestrationEvent); } catch (Exception ex) @@ -306,7 +306,7 @@ await context.CallActivityAsync( } finally { - transferOrchestrationEvent.OrchestrationEndTime = DateTime.UtcNow; + transferOrchestrationEvent.OrchestrationEndTime = context.CurrentUtcDateTime; _telemetryClient.TrackEvent(transferOrchestrationEvent); } } diff --git a/backend/CPS.ComplexCases.NetApp.Tests/CPS.ComplexCases.NetApp.Tests.csproj b/backend/CPS.ComplexCases.NetApp.Tests/CPS.ComplexCases.NetApp.Tests.csproj index 8fb1cb11f..25483817b 100644 --- a/backend/CPS.ComplexCases.NetApp.Tests/CPS.ComplexCases.NetApp.Tests.csproj +++ b/backend/CPS.ComplexCases.NetApp.Tests/CPS.ComplexCases.NetApp.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable diff --git a/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppClientTests.cs b/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppClientTests.cs index e0e31fb42..6c670fe40 100644 --- a/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppClientTests.cs +++ b/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppClientTests.cs @@ -1,5 +1,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using Microsoft.Extensions.Logging; @@ -159,7 +160,9 @@ public NetAppClientTests() _netAppS3HttpArgFactory = new NetAppS3HttpArgFactory(); - var testCert = new X509Certificate2([]); + using var rsa = RSA.Create(2048); + var certReq = new CertificateRequest("CN=TestCA", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var testCert = certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); var testCertCollection = new X509Certificate2Collection { testCert }; _mockNetAppCertFactory diff --git a/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppStorageClientTests.cs b/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppStorageClientTests.cs index 8154ffb75..5d1e9d026 100644 --- a/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppStorageClientTests.cs +++ b/backend/CPS.ComplexCases.NetApp.Tests/Integration/NetAppStorageClientTests.cs @@ -38,7 +38,6 @@ public class NetAppStorageClientTests : IDisposable public NetAppStorageClientTests() { - System.Net.ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, sslPolicyErrors) => true; _server = WireMockServer.Start(new WireMockServerSettings { UseSSL = true diff --git a/backend/CPS.ComplexCases.NetApp.Tests/Unit/Factories/S3ClientFactoryTests.cs b/backend/CPS.ComplexCases.NetApp.Tests/Unit/Factories/S3ClientFactoryTests.cs index 2010d4e04..7c963e112 100644 --- a/backend/CPS.ComplexCases.NetApp.Tests/Unit/Factories/S3ClientFactoryTests.cs +++ b/backend/CPS.ComplexCases.NetApp.Tests/Unit/Factories/S3ClientFactoryTests.cs @@ -48,7 +48,7 @@ public S3ClientFactoryTests() _telemetryHandlerMock = new Mock(); _netAppCertFactoryMock = new Mock(); - var testCert = new X509Certificate2([]); + var testCert = GenerateSelfSignedCertificate("CN=TestCA"); var testCertCollection = new X509Certificate2Collection { testCert }; _netAppCertFactoryMock diff --git a/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj b/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj index baa3822e2..e78a53535 100644 --- a/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj +++ b/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj @@ -2,7 +2,7 @@ Library - net8.0 + net10.0 enable enable true @@ -10,12 +10,12 @@ - - - - - - + + + + + + diff --git a/backend/CPS.ComplexCases.NetApp/CPS.ComplexCases.NetApp.csproj b/backend/CPS.ComplexCases.NetApp/CPS.ComplexCases.NetApp.csproj index 4a3f3b66e..2addac752 100644 --- a/backend/CPS.ComplexCases.NetApp/CPS.ComplexCases.NetApp.csproj +++ b/backend/CPS.ComplexCases.NetApp/CPS.ComplexCases.NetApp.csproj @@ -2,7 +2,7 @@ Library - net8.0 + net10.0 enable enable true @@ -16,8 +16,7 @@ - - + diff --git a/backend/CPS.ComplexCases.NetApp/Client/NetAppClient.cs b/backend/CPS.ComplexCases.NetApp/Client/NetAppClient.cs index e8bba7777..9cf589a44 100644 --- a/backend/CPS.ComplexCases.NetApp/Client/NetAppClient.cs +++ b/backend/CPS.ComplexCases.NetApp/Client/NetAppClient.cs @@ -13,7 +13,7 @@ using CPS.ComplexCases.NetApp.Models.Dto; using CPS.ComplexCases.NetApp.Wrappers; using Polly; -using Polly.Contrib.WaitAndRetry; +using Polly.Retry; namespace CPS.ComplexCases.NetApp.Client; @@ -367,10 +367,10 @@ private async Task UploadObjectCoreAsync(UploadObjectArg arg) public async Task UploadPartAsync(UploadPartArg arg) { - var retryPolicy = GetUploadPartRetryPolicy(arg.PartNumber, arg.ObjectKey); + var pipeline = GetUploadPartRetryPolicy(arg.PartNumber, arg.ObjectKey); try { - return await retryPolicy.ExecuteAsync(async () => + return await pipeline.ExecuteAsync(async ct => { // Re-resolve the S3 client on every attempt so that a credential // rotation triggered by a sibling task or another environment is picked up. @@ -388,7 +388,7 @@ private async Task UploadObjectCoreAsync(UploadObjectArg arg) DisablePayloadSigning = true }; return await s3Client.UploadPartAsync(request); - }); + }, CancellationToken.None); } catch (AmazonS3Exception ex) when (ex.ErrorCode == S3ErrorCodes.AccessDenied) { @@ -406,10 +406,10 @@ private async Task UploadObjectCoreAsync(UploadObjectArg arg) public async Task CompleteMultipartUploadAsync(CompleteMultipartUploadArg arg, CancellationToken cancellationToken = default) { - var retryPolicy = GetCompleteMultipartUploadRetryPolicy(arg.UploadId, arg.ObjectKey); + var pipeline = GetCompleteMultipartUploadRetryPolicy(arg.UploadId, arg.ObjectKey); try { - return await retryPolicy.ExecuteAsync(async ct => + return await pipeline.ExecuteAsync(async ct => { var s3Client = await _s3ClientFactory.GetS3ClientAsync(arg.BearerToken); return await s3Client.CompleteMultipartUploadAsync( @@ -984,76 +984,92 @@ private async Task DoesFolderExistInParentListingAsync(string bucketName, return false; } - private Polly.Retry.AsyncRetryPolicy GetDeleteFileRetryPolicy(string objectKey, - string bucketName) + private ResiliencePipeline GetDeleteFileRetryPolicy(string objectKey, string bucketName) { - return Policy - .HandleResult(r => r.HttpStatusCode != HttpStatusCode.NoContent) - .WaitAndRetryAsync( - Backoff.DecorrelatedJitterBackoffV2( - medianFirstRetryDelay: TimeSpan.FromSeconds(1), - retryCount: 3), - onRetry: (outcome, timespan, retryCount, context) => + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = static args => + ValueTask.FromResult(args.Outcome.Result?.HttpStatusCode != HttpStatusCode.NoContent), + OnRetry = args => { _logger.LogWarning( "Delete object retry attempt {RetryCount} for key {ObjectKey} in bucket {BucketName}. Status: {StatusCode}. Waiting {DelayMs}ms before next retry.", - retryCount, + args.AttemptNumber + 1, objectKey, bucketName, - outcome.Result?.HttpStatusCode, - timespan.TotalMilliseconds); - }); + args.Outcome.Result?.HttpStatusCode, + args.RetryDelay.TotalMilliseconds); + return ValueTask.CompletedTask; + } + }) + .Build(); } - private Polly.Retry.AsyncRetryPolicy GetUploadPartRetryPolicy(int partNumber, string objectKey) + private ResiliencePipeline GetUploadPartRetryPolicy(int partNumber, string objectKey) { // Retry when NetApp rejects the access key because credentials were rotated // by a concurrently running part upload or another environment sharing the same Key Vault. // On retry, InvalidateClientAsync + GetS3ClientAsync will force-regenerate fresh credentials. - return Policy - .HandleResult(r => false) - .Or(IsCredentialError) - .WaitAndRetryAsync( - retryCount: 2, - sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(retryAttempt * 3), - onRetryAsync: async (outcome, timespan, retryCount, context) => + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = TimeSpan.FromSeconds(3), + BackoffType = DelayBackoffType.Linear, + ShouldHandle = new PredicateBuilder() + .Handle(IsCredentialError), + OnRetry = async args => { - _logger.LogWarning(outcome.Exception, + _logger.LogWarning(args.Outcome.Exception, "Credential error uploading part {PartNumber} for {ObjectKey} - credentials likely rotated mid-transfer (StatusCode={StatusCode}, ErrorCode={ErrorCode}). Refreshing and retrying (attempt {RetryCount}/2). Waiting {DelayMs}ms.", partNumber, objectKey, - (outcome.Exception as AmazonS3Exception)?.StatusCode, - (outcome.Exception as AmazonS3Exception)?.ErrorCode, - retryCount, timespan.TotalMilliseconds); + (args.Outcome.Exception as AmazonS3Exception)?.StatusCode, + (args.Outcome.Exception as AmazonS3Exception)?.ErrorCode, + args.AttemptNumber + 1, + args.RetryDelay.TotalMilliseconds); // Invalidate the cached client ONLY on retry (not the first attempt) // so that GetS3ClientAsync regenerates credentials on the next call. await _s3ClientFactory.InvalidateClientAsync(); - }); + } + }) + .Build(); } - private Polly.Retry.AsyncRetryPolicy GetCompleteMultipartUploadRetryPolicy(string uploadId, string objectKey) + private ResiliencePipeline GetCompleteMultipartUploadRetryPolicy( + string uploadId, string objectKey) { - return Policy - .Handle(ex => (int)ex.StatusCode >= 500 - || ex.StatusCode == HttpStatusCode.RequestTimeout - || IsCredentialError(ex)) - .WaitAndRetryAsync( - Backoff.DecorrelatedJitterBackoffV2( - medianFirstRetryDelay: TimeSpan.FromSeconds(3), - retryCount: 5), - onRetryAsync: async (exception, timespan, retryCount, context) => + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 5, + Delay = TimeSpan.FromSeconds(3), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = new PredicateBuilder() + .Handle(ex => (int)ex.StatusCode >= 500 + || ex.StatusCode == HttpStatusCode.RequestTimeout + || IsCredentialError(ex)), + OnRetry = async args => { - _logger.LogWarning(exception, + _logger.LogWarning(args.Outcome.Exception, "CompleteMultipartUpload retry attempt {RetryCount} for upload {UploadId} ({ObjectKey}). Waiting {DelayMs}ms.", - retryCount, uploadId, objectKey, timespan.TotalMilliseconds); + args.AttemptNumber + 1, uploadId, objectKey, args.RetryDelay.TotalMilliseconds); // Force credential regeneration on retry so the next attempt // gets fresh keys from NetApp instead of reusing the dead cache. - if (exception is AmazonS3Exception s3Ex && IsCredentialError(s3Ex)) + if (args.Outcome.Exception is AmazonS3Exception s3Ex && IsCredentialError(s3Ex)) { await _s3ClientFactory.InvalidateClientAsync(); } - }); + } + }) + .Build(); } private static bool IsCredentialError(AmazonS3Exception ex) diff --git a/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs index 6a03c844d..22348fbeb 100644 --- a/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Http.Resilience; using Azure.Identity; using Azure.Security.KeyVault.Secrets; using CPS.ComplexCases.NetApp.Client; @@ -11,8 +12,6 @@ using CPS.ComplexCases.NetApp.Telemetry; using CPS.ComplexCases.NetApp.Wrappers; using Polly; -using Polly.Contrib.WaitAndRetry; -using Polly.Wrap; namespace CPS.ComplexCases.NetApp.Extensions; @@ -67,7 +66,7 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat }) .ConfigurePrimaryHttpMessageHandler(sp => CreateHttpClientHandler(sp, isDevelopment)) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddPolicyHandler(GetRetryPolicy()); + .AddResilienceHandler("netapp-retry", ConfigureResiliencePipeline); services.AddHttpClient(client => { @@ -83,7 +82,7 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat .ConfigurePrimaryHttpMessageHandler(sp => CreateHttpClientHandler(sp, isDevelopment) ) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddPolicyHandler(GetRetryPolicy()); + .AddResilienceHandler("netapp-s3-retry", ConfigureResiliencePipeline); services.AddTransient(); } @@ -115,30 +114,31 @@ private static HttpClientHandler CreateHttpClientHandler(IServiceProvider sp, bo } } - private static AsyncPolicyWrap GetRetryPolicy() + private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder pipeline) { - var bulkheadPolicy = Policy.BulkheadAsync( - maxParallelization: 30, - maxQueuingActions: int.MaxValue - ); + // Limit concurrent requests to avoid overwhelming NetApp (equivalent to Polly v7 bulkhead) + pipeline.AddConcurrencyLimiter(permitLimit: 30, queueLimit: int.MaxValue); - // https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly#add-a-jitter-strategy-to-the-retry-policy - var delay = Backoff.DecorrelatedJitterBackoffV2( - medianFirstRetryDelay: TimeSpan.FromSeconds(FirstRetryDelaySeconds), - retryCount: RetryAttempts); - - static bool responseStatusCodePredicate(HttpResponseMessage response) => - response.StatusCode >= HttpStatusCode.InternalServerError - || response.StatusCode == HttpStatusCode.TooManyRequests; - - static bool methodPredicate(HttpResponseMessage response) => - response.RequestMessage?.Method != HttpMethod.Post - && response.RequestMessage?.Method != HttpMethod.Put; + // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience + pipeline.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = RetryAttempts, + Delay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = static args => + { + if (args.Outcome.Result is null) + return ValueTask.FromResult(false); - var retryPolicy = Policy - .HandleResult(r => responseStatusCodePredicate(r) && methodPredicate(r)) - .WaitAndRetryAsync(delay); + var response = args.Outcome.Result; + var isRetryableStatus = response.StatusCode >= HttpStatusCode.InternalServerError + || response.StatusCode == HttpStatusCode.TooManyRequests; + var isRetryableMethod = response.RequestMessage?.Method != HttpMethod.Post + && response.RequestMessage?.Method != HttpMethod.Put; - return Policy.WrapAsync(bulkheadPolicy, retryPolicy); + return ValueTask.FromResult(isRetryableStatus && isRetryableMethod); + } + }); } } \ No newline at end of file diff --git a/backend/CPS.ComplexCases.NetApp/Factories/NetAppCertFactory.cs b/backend/CPS.ComplexCases.NetApp/Factories/NetAppCertFactory.cs index d469d2217..382eda334 100644 --- a/backend/CPS.ComplexCases.NetApp/Factories/NetAppCertFactory.cs +++ b/backend/CPS.ComplexCases.NetApp/Factories/NetAppCertFactory.cs @@ -28,7 +28,7 @@ public X509Certificate2Collection GetTrustedCaCertificates() try { var rootCaBytes = Convert.FromBase64String(rootCaBase64); - var rootCaCert = new X509Certificate2(rootCaBytes); + var rootCaCert = X509CertificateLoader.LoadCertificate(rootCaBytes); _trustedCaCertificates.Add(rootCaCert); } catch (Exception ex) @@ -46,7 +46,7 @@ public X509Certificate2Collection GetTrustedCaCertificates() try { var issuingCaBytes = Convert.FromBase64String(issuingCaBase64); - var issuingCaCert = new X509Certificate2(issuingCaBytes); + var issuingCaCert = X509CertificateLoader.LoadCertificate(issuingCaBytes); _trustedCaCertificates.Add(issuingCaCert); } catch (Exception ex) @@ -64,7 +64,7 @@ public X509Certificate2Collection GetTrustedCaCertificates() try { var issuingCa2Bytes = Convert.FromBase64String(issuingCa2Base64); - var issuingCa2Cert = new X509Certificate2(issuingCa2Bytes); + var issuingCa2Cert = X509CertificateLoader.LoadCertificate(issuingCa2Bytes); _trustedCaCertificates.Add(issuingCa2Cert); } catch (Exception ex) diff --git a/backend/CPS.ComplexCases.NetApp/Factories/S3ClientFactory.cs b/backend/CPS.ComplexCases.NetApp/Factories/S3ClientFactory.cs index 2bdccce91..70895458a 100644 --- a/backend/CPS.ComplexCases.NetApp/Factories/S3ClientFactory.cs +++ b/backend/CPS.ComplexCases.NetApp/Factories/S3ClientFactory.cs @@ -1,5 +1,4 @@ using System.IdentityModel.Tokens.Jwt; -using System.Net; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Amazon; @@ -140,9 +139,7 @@ private async Task CreateS3Client(string bearerToken, bool forceRegen } else if (isDevelopment) { - // In Development, bypass all SSL validation - ServicePointManager.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true; - + // In Development, bypass all SSL validation via HttpClientHandler var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true @@ -157,8 +154,6 @@ private async Task CreateS3Client(string bearerToken, bool forceRegen "Please ensure that the Root CA and Issuing CA certificates are correctly configured in Key Vault."); } - ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls13; - var s3Client = new AmazonS3Client(credentials, s3Config); s3Client.BeforeRequestEvent += (sender, args) => diff --git a/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj b/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj index 100728de8..639ded191 100644 --- a/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj +++ b/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable true diff --git a/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj b/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj index 346dc5dd0..9ebd699e5 100644 --- a/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj +++ b/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 enable enable true diff --git a/backend/Cps.ComplexCases.Data.Tests/Cps.ComplexCases.Data.Tests.csproj b/backend/Cps.ComplexCases.Data.Tests/Cps.ComplexCases.Data.Tests.csproj index be8a6248d..3d4562da0 100644 --- a/backend/Cps.ComplexCases.Data.Tests/Cps.ComplexCases.Data.Tests.csproj +++ b/backend/Cps.ComplexCases.Data.Tests/Cps.ComplexCases.Data.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable @@ -20,7 +20,7 @@ - + diff --git a/devops-pipelines/backend/backend-build-and-deploy.yml b/devops-pipelines/backend/backend-build-and-deploy.yml index 85bc7f2c6..2255e7ec0 100644 --- a/devops-pipelines/backend/backend-build-and-deploy.yml +++ b/devops-pipelines/backend/backend-build-and-deploy.yml @@ -13,10 +13,10 @@ variables: - group: lacc-deployment-global - name: buildConfiguration value: 'Release' - - name: dotNetVersion - value: '8.x' - - name: efVersion - value: '8.0.*' +- name: dotNetVersion + value: '10.x' +- name: efVersion + value: '10.0.*' - name: asposeLicenceFileName value: 'Aspose.Total.NET.lic' diff --git a/devops-pipelines/backend/backend-pr-build-and-test.yml b/devops-pipelines/backend/backend-pr-build-and-test.yml index 1f73fdb28..55fd6ab9a 100644 --- a/devops-pipelines/backend/backend-pr-build-and-test.yml +++ b/devops-pipelines/backend/backend-pr-build-and-test.yml @@ -12,9 +12,9 @@ variables: - name: buildConfiguration value: 'Release' - name: dotNetVersion - value: '8.x' + value: '10.x' - name: efVersion - value: '8.0.*' + value: '10.0.*' - name: testResultsDirectory value: '$(Agent.TempDirectory)/TestResults' diff --git a/devops-pipelines/backend/backend-pr-integration-tests.yml b/devops-pipelines/backend/backend-pr-integration-tests.yml index 21cfd575c..16a3364d8 100644 --- a/devops-pipelines/backend/backend-pr-integration-tests.yml +++ b/devops-pipelines/backend/backend-pr-integration-tests.yml @@ -18,9 +18,9 @@ variables: - name: buildConfiguration value: "Release" - name: dotNetVersion - value: "8.x" + value: "10.x" - name: efVersion - value: "8.0.*" + value: "10.0.*" - name: testResultsDirectory value: "$(Agent.TempDirectory)/TestResults" diff --git a/devops-pipelines/templates/dotnet-build-steps.yml b/devops-pipelines/templates/dotnet-build-steps.yml index f79b905d1..b5c4a2f7e 100644 --- a/devops-pipelines/templates/dotnet-build-steps.yml +++ b/devops-pipelines/templates/dotnet-build-steps.yml @@ -2,7 +2,7 @@ parameters: projectPath: '' projectName: '' buildConfiguration: 'Release' - dotNetVersion: '8.x' + dotNetVersion: '10.x' useLocalNuGet: false nugetArtifact: 'localnuget' nugetArtifactDownloadPath: '$(Build.SourcesDirectory)/localnuget' diff --git a/devops-pipelines/templates/fa-config-steps.yml b/devops-pipelines/templates/fa-config-steps.yml index f5005a57c..6849d419d 100644 --- a/devops-pipelines/templates/fa-config-steps.yml +++ b/devops-pipelines/templates/fa-config-steps.yml @@ -256,7 +256,7 @@ steps: }, { "name": "DOTNET_FRAMEWORK_VERSION", - "value": "v8.0", + "value": "v10.0", "slotSetting": false } ] diff --git a/devops-pipelines/templates/fa-deploy-steps.yml b/devops-pipelines/templates/fa-deploy-steps.yml index 77fdc5fd0..14332249a 100644 --- a/devops-pipelines/templates/fa-deploy-steps.yml +++ b/devops-pipelines/templates/fa-deploy-steps.yml @@ -34,7 +34,7 @@ steps: package: '$(Pipeline.Workspace)/${{ parameters.buildArtifactName }}/*.zip' ${{ else }}: package: '$(Pipeline.Workspace)/${{ parameters.pipelineResource}}/${{ parameters.buildArtifactName }}/*.zip' - runtimeStack: 'DOTNET-ISOLATED|8.0' + runtimeStack: 'DOTNET-ISOLATED|10.0' deploymentMethod: 'runFromPackage' deployToSlotOrASE: ${{ parameters.deployToSlot }} slotName: ${{ parameters.slotName }} diff --git a/devops-pipelines/ui/ui-pr-build-and-test.yml b/devops-pipelines/ui/ui-pr-build-and-test.yml index 1ca2f40a5..da6dfc8d7 100644 --- a/devops-pipelines/ui/ui-pr-build-and-test.yml +++ b/devops-pipelines/ui/ui-pr-build-and-test.yml @@ -70,7 +70,7 @@ stages: displayName: 'Use .NET SDK to publish Code Coverage' inputs: packageType: 'sdk' - version: '8.x' + version: '10.x' - script: | export PATH="$PATH:$HOME/.dotnet/tools" diff --git a/scripts/build-and-test-backend-local.ps1 b/scripts/build-and-test-backend-local.ps1 index f417106aa..2b5ab6258 100644 --- a/scripts/build-and-test-backend-local.ps1 +++ b/scripts/build-and-test-backend-local.ps1 @@ -87,7 +87,7 @@ try { } catch { Write-Host "ERROR .NET SDK not found" -ForegroundColor Red - Write-Host "Please install .NET 8 SDK from https://dotnet.microsoft.com/download" -ForegroundColor Yellow + Write-Host "Please install .NET 10 SDK from https://dotnet.microsoft.com/download" -ForegroundColor Yellow exit 1 } diff --git a/scripts/build-and-test-local.ps1 b/scripts/build-and-test-local.ps1 index 8142c8f35..d361b9236 100644 --- a/scripts/build-and-test-local.ps1 +++ b/scripts/build-and-test-local.ps1 @@ -118,7 +118,7 @@ try { } catch { Write-Host "ERROR .NET SDK not found" -ForegroundColor Red - Write-Host "Please install .NET 8 SDK from https://dotnet.microsoft.com/download" -ForegroundColor Yellow + Write-Host "Please install .NET 10 SDK from https://dotnet.microsoft.com/download" -ForegroundColor Yellow exit 1 } From 6f87ea8e1ce847263c2a5440de0d4f088d2cbea6 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 19 May 2026 16:06:42 +0100 Subject: [PATCH 2/8] .net upgrade --- backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj b/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj index f2ec9d7d3..e3555b301 100644 --- a/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj +++ b/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj @@ -7,6 +7,7 @@ enable true <_FunctionsSkipCleanOutput>true + <_WorkerExtensionsTargetFramework>net10.0 true @@ -22,7 +23,7 @@ - + From aa0e27fd603a769f4fbb9eb9d0d26a13ee370e31 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 19 May 2026 16:20:36 +0100 Subject: [PATCH 3/8] .net 10 --- .../CPS.ComplexCases.API.HttpTelemetry.csproj | 2 +- backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj b/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj index 8bfd4e376..51c4dc163 100644 --- a/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj +++ b/backend/CPS.ComplexCases.API.HttpTelemetry/CPS.ComplexCases.API.HttpTelemetry.csproj @@ -4,7 +4,7 @@ 1.0.41 - net10.0 + net8.0;net10.0 enable enable diff --git a/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj b/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj index e3555b301..1c1258829 100644 --- a/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj +++ b/backend/CPS.ComplexCases.API/CPS.ComplexCases.API.csproj @@ -7,7 +7,6 @@ enable true <_FunctionsSkipCleanOutput>true - <_WorkerExtensionsTargetFramework>net10.0 true From 43147ab33be7bddf5c6e7db99987febe8282c7ad Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 20 May 2026 08:58:56 +0100 Subject: [PATCH 4/8] refactoring --- .../CPS.ComplexCases.Common.csproj | 1 + .../ResiliencePipelineExtensions.cs | 42 +++++++++++++++++++ .../Extensions/IServiceCollectionExtension.cs | 39 ++--------------- .../Extensions/IServiceCollectionExtension.cs | 38 ++--------------- 4 files changed, 49 insertions(+), 71 deletions(-) create mode 100644 backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs diff --git a/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj b/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj index d3287877c..eaa744838 100644 --- a/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj +++ b/backend/CPS.ComplexCases.Common/CPS.ComplexCases.Common.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs b/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs new file mode 100644 index 000000000..369b70d74 --- /dev/null +++ b/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs @@ -0,0 +1,42 @@ +using System.Net; +using Microsoft.Extensions.Http.Resilience; +using Polly; + +namespace CPS.ComplexCases.Common.Extensions; + +public static class ResiliencePipelineExtensions +{ + private const int RetryAttempts = 3; + private const int FirstRetryDelaySeconds = 1; + + public static ResiliencePipelineBuilder AddStandardHttpResilience( + this ResiliencePipelineBuilder pipeline, + int concurrencyPermitLimit = 30) + { + pipeline.AddConcurrencyLimiter(permitLimit: concurrencyPermitLimit, queueLimit: int.MaxValue); + + // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience + pipeline.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = RetryAttempts, + Delay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = static args => + { + if (args.Outcome.Result is null) + return ValueTask.FromResult(false); + + var response = args.Outcome.Result; + var isRetryableStatus = response.StatusCode >= HttpStatusCode.InternalServerError + || response.StatusCode == HttpStatusCode.TooManyRequests; + var isRetryableMethod = response.RequestMessage?.Method != HttpMethod.Post + && response.RequestMessage?.Method != HttpMethod.Put; + + return ValueTask.FromResult(isRetryableStatus && isRetryableMethod); + } + }); + + return pipeline; + } +} diff --git a/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs index a950d709b..4a321af1f 100644 --- a/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs @@ -1,18 +1,13 @@ -using System.Net; +using CPS.ComplexCases.Common.Extensions; using CPS.ComplexCases.Egress.Client; using CPS.ComplexCases.Egress.Factories; using CPS.ComplexCases.Egress.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; -using Polly; - namespace CPS.ComplexCases.Egress.Extensions; public static class IServiceCollectionExtension { - private const int RetryAttempts = 3; - private const int FirstRetryDelaySeconds = 1; public static void AddEgressClient(this IServiceCollection services, IConfiguration configuration) { services.AddTransient(); @@ -29,7 +24,7 @@ public static void AddEgressClient(this IServiceCollection services, IConfigurat client.Timeout = TimeSpan.FromMinutes(10); }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("egress-retry", ConfigureResiliencePipeline); + .AddResilienceHandler("egress-retry", p => p.AddStandardHttpResilience()); services.AddHttpClient(client => { @@ -42,34 +37,6 @@ public static void AddEgressClient(this IServiceCollection services, IConfigurat client.Timeout = TimeSpan.FromMinutes(10); }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("egress-storage-retry", ConfigureResiliencePipeline); - } - - private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder pipeline) - { - // Egress API has rate limiting so limit concurrency (equivalent to Polly v7 bulkhead) - pipeline.AddConcurrencyLimiter(permitLimit: 30, queueLimit: int.MaxValue); - - // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience - pipeline.AddRetry(new HttpRetryStrategyOptions - { - MaxRetryAttempts = RetryAttempts, - Delay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), - BackoffType = DelayBackoffType.Exponential, - UseJitter = true, - ShouldHandle = static args => - { - if (args.Outcome.Result is null) - return ValueTask.FromResult(false); - - var response = args.Outcome.Result; - var isRetryableStatus = response.StatusCode >= HttpStatusCode.InternalServerError - || response.StatusCode == HttpStatusCode.TooManyRequests; - var isRetryableMethod = response.RequestMessage?.Method != HttpMethod.Post - && response.RequestMessage?.Method != HttpMethod.Put; - - return ValueTask.FromResult(isRetryableStatus && isRetryableMethod); - } - }); + .AddResilienceHandler("egress-storage-retry", p => p.AddStandardHttpResilience()); } } \ No newline at end of file diff --git a/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs index 22348fbeb..e1a489b67 100644 --- a/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs @@ -1,25 +1,20 @@ -using System.Net; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Http.Resilience; using Azure.Identity; using Azure.Security.KeyVault.Secrets; +using CPS.ComplexCases.Common.Extensions; using CPS.ComplexCases.NetApp.Client; using CPS.ComplexCases.NetApp.Factories; using CPS.ComplexCases.NetApp.Models; using CPS.ComplexCases.NetApp.Services; using CPS.ComplexCases.NetApp.Telemetry; using CPS.ComplexCases.NetApp.Wrappers; -using Polly; namespace CPS.ComplexCases.NetApp.Extensions; public static class IServiceCollectionExtension { - private const int RetryAttempts = 3; - private const int FirstRetryDelaySeconds = 1; - public static void AddNetAppClient(this IServiceCollection services, IConfiguration configuration) { services.AddDefaultAWSOptions(configuration.GetAWSOptions()); @@ -66,7 +61,7 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat }) .ConfigurePrimaryHttpMessageHandler(sp => CreateHttpClientHandler(sp, isDevelopment)) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("netapp-retry", ConfigureResiliencePipeline); + .AddResilienceHandler("netapp-retry", p => p.AddStandardHttpResilience()); services.AddHttpClient(client => { @@ -82,7 +77,7 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat .ConfigurePrimaryHttpMessageHandler(sp => CreateHttpClientHandler(sp, isDevelopment) ) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("netapp-s3-retry", ConfigureResiliencePipeline); + .AddResilienceHandler("netapp-s3-retry", p => p.AddStandardHttpResilience()); services.AddTransient(); } @@ -114,31 +109,4 @@ private static HttpClientHandler CreateHttpClientHandler(IServiceProvider sp, bo } } - private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder pipeline) - { - // Limit concurrent requests to avoid overwhelming NetApp (equivalent to Polly v7 bulkhead) - pipeline.AddConcurrencyLimiter(permitLimit: 30, queueLimit: int.MaxValue); - - // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience - pipeline.AddRetry(new HttpRetryStrategyOptions - { - MaxRetryAttempts = RetryAttempts, - Delay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), - BackoffType = DelayBackoffType.Exponential, - UseJitter = true, - ShouldHandle = static args => - { - if (args.Outcome.Result is null) - return ValueTask.FromResult(false); - - var response = args.Outcome.Result; - var isRetryableStatus = response.StatusCode >= HttpStatusCode.InternalServerError - || response.StatusCode == HttpStatusCode.TooManyRequests; - var isRetryableMethod = response.RequestMessage?.Method != HttpMethod.Post - && response.RequestMessage?.Method != HttpMethod.Put; - - return ValueTask.FromResult(isRetryableStatus && isRetryableMethod); - } - }); - } } \ No newline at end of file From b086264311fff335a60153f93d94ec7d5c9f334c Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 22 May 2026 08:43:20 +0100 Subject: [PATCH 5/8] indentation fix --- devops-pipelines/backend/backend-build-and-deploy.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/devops-pipelines/backend/backend-build-and-deploy.yml b/devops-pipelines/backend/backend-build-and-deploy.yml index 2255e7ec0..dac1d68a1 100644 --- a/devops-pipelines/backend/backend-build-and-deploy.yml +++ b/devops-pipelines/backend/backend-build-and-deploy.yml @@ -13,10 +13,10 @@ variables: - group: lacc-deployment-global - name: buildConfiguration value: 'Release' -- name: dotNetVersion - value: '10.x' -- name: efVersion - value: '10.0.*' + - name: dotNetVersion + value: '10.x' + - name: efVersion + value: '10.0.*' - name: asposeLicenceFileName value: 'Aspose.Total.NET.lic' From 091bd3a6639864ffa0fdeb6e1d6b97d35915afb4 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 29 Jun 2026 14:44:30 +0100 Subject: [PATCH 6/8] duplication and syntax --- .../ResiliencePipelineExtensions.cs | 15 ++++++ .../Extensions/IServiceCollectionExtension.cs | 49 ++++++++----------- .../Extensions/IServiceCollectionExtension.cs | 45 +++++++---------- .../Extensions/IServiceCollectionExtension.cs | 45 +++++++---------- .../backend/backend-pr-build-and-test.yml | 16 +++--- 5 files changed, 79 insertions(+), 91 deletions(-) diff --git a/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs b/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs index 22520430d..4a6d3237e 100644 --- a/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs +++ b/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs @@ -1,5 +1,6 @@ using System.Net; using CPS.ComplexCases.Common.Resilience; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Polly; @@ -8,6 +9,20 @@ namespace CPS.ComplexCases.Common.Extensions; public static class ResiliencePipelineExtensions { + // Builds the standard resilience-handler configuration delegate shared by every service client. + // Each caller supplies only the logger category and its tuned options; the logger creation and + // pipeline wiring are identical, so they live here to avoid duplicating them per project. + public static Action, ResilienceHandlerContext> + ConfigureStandardResilience(string loggerCategory, HttpResilienceOptions options) => + (pipeline, context) => + { + var logger = context.ServiceProvider + .GetRequiredService() + .CreateLogger(loggerCategory); + + pipeline.AddStandardHttpResilience(options, logger); + }; + // Configures a standard HTTP resilience pipeline built on Microsoft.Extensions.Http.Resilience // (Polly v8). Strategies are added outer-to-inner: concurrency limiter, then retry, then circuit // breaker. Retry sits outside the breaker so a retry attempt re-enters the (possibly open) circuit diff --git a/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs index 6d737b59b..d9d991329 100644 --- a/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.DDEI/Extensions/IServiceCollectionExtension.cs @@ -9,10 +9,7 @@ using CPS.ComplexCases.DDEI.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Polly; namespace CPS.ComplexCases.DDEI.Extensions; @@ -32,9 +29,28 @@ public static void AddDdeiClient(this IServiceCollection services, IConfiguratio { services.Configure(configuration.GetSection(nameof(DDEIOptions))); services.AddTransient(); + + // A 404 is retried (MDS occasionally returns one transiently) but is not a health signal, so it is + // deliberately excluded from the breaker. Connection failures are also retried for this service. + var configureResilience = ResiliencePipelineExtensions.ConfigureStandardResilience( + "CPS.ComplexCases.DDEI.CircuitBreaker", + new HttpResilienceOptions + { + ServiceName = "MDS (DDEI)", + RetryAttempts = RetryAttempts, + FirstRetryDelay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), + CircuitBreakerFailureThreshold = CircuitBreakerFailureThreshold, + CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(CircuitBreakerSamplingDurationSeconds), + CircuitBreakerMinimumThroughput = CircuitBreakerMinimumThroughput, + CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(CircuitBreakerDurationOfBreakSeconds), + ConcurrencyLimit = 0, + RetryOnConnectionFailure = true, + AdditionalRetryableStatusCodes = [HttpStatusCode.NotFound], + }); + services.AddHttpClient(AddDdeiClient) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("ddei-resilience", ConfigureResiliencePipeline); + .AddResilienceHandler("ddei-resilience", configureResilience); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -54,29 +70,4 @@ internal static void AddDdeiClient(IServiceProvider configuration, HttpClient cl client.DefaultRequestHeaders.Add(DDEIOptions.DevtunnelTokenKey, opts.DevtunnelToken); } } - - // A 404 is retried (MDS occasionally returns one transiently) but is not a health signal, so it is - // deliberately excluded from the breaker. Connection failures are also retried for this service. - private static void ConfigureResiliencePipeline( - ResiliencePipelineBuilder pipeline, - ResilienceHandlerContext context) - { - var logger = context.ServiceProvider - .GetRequiredService() - .CreateLogger("CPS.ComplexCases.DDEI.CircuitBreaker"); - - pipeline.AddStandardHttpResilience(new HttpResilienceOptions - { - ServiceName = "MDS (DDEI)", - RetryAttempts = RetryAttempts, - FirstRetryDelay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), - CircuitBreakerFailureThreshold = CircuitBreakerFailureThreshold, - CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(CircuitBreakerSamplingDurationSeconds), - CircuitBreakerMinimumThroughput = CircuitBreakerMinimumThroughput, - CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(CircuitBreakerDurationOfBreakSeconds), - ConcurrencyLimit = 0, - RetryOnConnectionFailure = true, - AdditionalRetryableStatusCodes = [HttpStatusCode.NotFound], - }, logger); - } } diff --git a/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs index 0fd4683cd..72223e09a 100644 --- a/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.Egress/Extensions/IServiceCollectionExtension.cs @@ -6,9 +6,6 @@ using CPS.ComplexCases.Egress.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; -using Microsoft.Extensions.Logging; -using Polly; namespace CPS.ComplexCases.Egress.Extensions; @@ -30,6 +27,22 @@ public static void AddEgressClient(this IServiceCollection services, IConfigurat services.AddTransient(); services.AddTransient(); services.Configure(configuration.GetSection("EgressOptions")); + + var configureResilience = ResiliencePipelineExtensions.ConfigureStandardResilience( + "CPS.ComplexCases.Egress.CircuitBreaker", + new HttpResilienceOptions + { + ServiceName = "Egress", + RetryAttempts = RetryAttempts, + FirstRetryDelay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), + CircuitBreakerFailureThreshold = CircuitBreakerFailureThreshold, + CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(CircuitBreakerSamplingDurationSeconds), + CircuitBreakerMinimumThroughput = CircuitBreakerMinimumThroughput, + CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(CircuitBreakerDurationOfBreakSeconds), + ConcurrencyLimit = ConcurrencyLimit, + AdditionalRetryableStatusCodes = [HttpStatusCode.TooManyRequests], + }); + services.AddHttpClient(client => { var egressServiceUrl = configuration["EgressOptions:Url"]; @@ -41,7 +54,7 @@ public static void AddEgressClient(this IServiceCollection services, IConfigurat client.Timeout = TimeSpan.FromSeconds(configuration.GetValue("EgressOptions:ManagementTimeoutSeconds", 100)); }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("egress-resilience", ConfigureResiliencePipeline); + .AddResilienceHandler("egress-resilience", configureResilience); services.AddHttpClient(client => { @@ -58,28 +71,6 @@ public static void AddEgressClient(this IServiceCollection services, IConfigurat client.Timeout = Timeout.InfiniteTimeSpan; }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("egress-storage-resilience", ConfigureResiliencePipeline); - } - - private static void ConfigureResiliencePipeline( - ResiliencePipelineBuilder pipeline, - ResilienceHandlerContext context) - { - var logger = context.ServiceProvider - .GetRequiredService() - .CreateLogger("CPS.ComplexCases.Egress.CircuitBreaker"); - - pipeline.AddStandardHttpResilience(new HttpResilienceOptions - { - ServiceName = "Egress", - RetryAttempts = RetryAttempts, - FirstRetryDelay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), - CircuitBreakerFailureThreshold = CircuitBreakerFailureThreshold, - CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(CircuitBreakerSamplingDurationSeconds), - CircuitBreakerMinimumThroughput = CircuitBreakerMinimumThroughput, - CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(CircuitBreakerDurationOfBreakSeconds), - ConcurrencyLimit = ConcurrencyLimit, - AdditionalRetryableStatusCodes = [HttpStatusCode.TooManyRequests], - }, logger); + .AddResilienceHandler("egress-storage-resilience", configureResilience); } } \ No newline at end of file diff --git a/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs index b76dcf615..0a42a5a27 100644 --- a/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.NetApp/Extensions/IServiceCollectionExtension.cs @@ -1,7 +1,6 @@ using System.Net; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Azure.Identity; using Azure.Security.KeyVault.Secrets; @@ -14,7 +13,6 @@ using CPS.ComplexCases.NetApp.Telemetry; using CPS.ComplexCases.NetApp.Wrappers; using CPS.ComplexCases.Common.Resilience; -using Polly; namespace CPS.ComplexCases.NetApp.Extensions; @@ -68,6 +66,21 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat var isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development"; + var configureResilience = ResiliencePipelineExtensions.ConfigureStandardResilience( + "CPS.ComplexCases.NetApp.CircuitBreaker", + new HttpResilienceOptions + { + ServiceName = "NetApp", + RetryAttempts = RetryAttempts, + FirstRetryDelay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), + CircuitBreakerFailureThreshold = CircuitBreakerFailureThreshold, + CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(CircuitBreakerSamplingDurationSeconds), + CircuitBreakerMinimumThroughput = CircuitBreakerMinimumThroughput, + CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(CircuitBreakerDurationOfBreakSeconds), + ConcurrencyLimit = ConcurrencyLimit, + AdditionalRetryableStatusCodes = [HttpStatusCode.TooManyRequests], + }); + services.AddHttpClient(client => { var netAppServiceUrl = configuration["NetAppOptions:ClusterUrl"]; @@ -81,7 +94,7 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat }) .ConfigurePrimaryHttpMessageHandler(sp => CreateHttpClientHandler(sp, isDevelopment)) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("netapp-resilience", ConfigureResiliencePipeline); + .AddResilienceHandler("netapp-resilience", configureResilience); services.AddHttpClient(client => { @@ -97,7 +110,7 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat .ConfigurePrimaryHttpMessageHandler(sp => CreateHttpClientHandler(sp, isDevelopment) ) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("netapp-s3-resilience", ConfigureResiliencePipeline); + .AddResilienceHandler("netapp-s3-resilience", configureResilience); services.AddHttpClient(client => { @@ -112,7 +125,7 @@ public static void AddNetAppClient(this IServiceCollection services, IConfigurat }) .ConfigurePrimaryHttpMessageHandler(sp => CreateHttpClientHandler(sp, isDevelopment)) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddResilienceHandler("ontap-resilience", ConfigureResiliencePipeline); + .AddResilienceHandler("ontap-resilience", configureResilience); services.AddTransient(); } @@ -143,26 +156,4 @@ private static HttpClientHandler CreateHttpClientHandler(IServiceProvider sp, bo "Ensure RootCaCert, IssuingCaCert, and/or IssuingCaCert2 are correctly configured."); } } - - private static void ConfigureResiliencePipeline( - ResiliencePipelineBuilder pipeline, - ResilienceHandlerContext context) - { - var logger = context.ServiceProvider - .GetRequiredService() - .CreateLogger("CPS.ComplexCases.NetApp.CircuitBreaker"); - - pipeline.AddStandardHttpResilience(new HttpResilienceOptions - { - ServiceName = "NetApp", - RetryAttempts = RetryAttempts, - FirstRetryDelay = TimeSpan.FromSeconds(FirstRetryDelaySeconds), - CircuitBreakerFailureThreshold = CircuitBreakerFailureThreshold, - CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(CircuitBreakerSamplingDurationSeconds), - CircuitBreakerMinimumThroughput = CircuitBreakerMinimumThroughput, - CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(CircuitBreakerDurationOfBreakSeconds), - ConcurrencyLimit = ConcurrencyLimit, - AdditionalRetryableStatusCodes = [HttpStatusCode.TooManyRequests], - }, logger); - } } \ No newline at end of file diff --git a/devops-pipelines/backend/backend-pr-build-and-test.yml b/devops-pipelines/backend/backend-pr-build-and-test.yml index bb9a23577..884dd1e4c 100644 --- a/devops-pipelines/backend/backend-pr-build-and-test.yml +++ b/devops-pipelines/backend/backend-pr-build-and-test.yml @@ -9,14 +9,14 @@ pr: - backend/* variables: -- name: buildConfiguration - value: 'Release' -- name: dotNetVersion - value: '10.x' -- name: efVersion - value: '10.0.*' -- name: testResultsDirectory - value: '$(Agent.TempDirectory)/TestResults' + - name: buildConfiguration + value: 'Release' + - name: dotNetVersion + value: '10.x' + - name: efVersion + value: '10.0.*' + - name: testResultsDirectory + value: '$(Agent.TempDirectory)/TestResults' pool: 'LACC PreProd Pool' From 671be9d59502594de122e70a54d6769bdf743b75 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 7 Jul 2026 12:20:02 +0100 Subject: [PATCH 7/8] fix: deprecation warnings and merge main --- .../CPS.ComplexCases.DDEI.WireMock.csproj | 2 ++ .../CPS.ComplexCases.Egress.WireMock.csproj | 2 ++ .../CPS.ComplexCases.NetApp.WireMock.csproj | 2 ++ .../CPS.ComplexCases.WireMock.Core.csproj | 2 ++ .../CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj | 2 ++ .../Cps.ComplexCases.Data.Tests.csproj | 2 ++ 6 files changed, 12 insertions(+) diff --git a/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj b/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj index 8072fbba9..691e27212 100644 --- a/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj +++ b/backend/CPS.ComplexCases.DDEI.WireMock/CPS.ComplexCases.DDEI.WireMock.csproj @@ -10,6 +10,8 @@ + + diff --git a/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj b/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj index 762c02179..f6944b330 100644 --- a/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj +++ b/backend/CPS.ComplexCases.Egress.WireMock/CPS.ComplexCases.Egress.WireMock.csproj @@ -10,6 +10,8 @@ + + diff --git a/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj b/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj index e78a53535..0f4d05da0 100644 --- a/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj +++ b/backend/CPS.ComplexCases.NetApp.WireMock/CPS.ComplexCases.NetApp.WireMock.csproj @@ -17,6 +17,8 @@ + + diff --git a/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj b/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj index 639ded191..790f7d6a8 100644 --- a/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj +++ b/backend/CPS.ComplexCases.WireMock.Core/CPS.ComplexCases.WireMock.Core.csproj @@ -9,6 +9,8 @@ + + \ No newline at end of file diff --git a/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj b/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj index 9ebd699e5..f8bc2852b 100644 --- a/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj +++ b/backend/CPS.ComplexCases.WireMock/CPS.ComplexCases.WireMock.csproj @@ -10,6 +10,8 @@ + + + + From 6c232a499dcc71bdb2dda463a4f247aeca4dfee2 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 20 Jul 2026 13:17:58 +0100 Subject: [PATCH 8/8] pr comments --- .../Extensions/IServiceCollectionExtension.cs | 35 ++++--- .../ResiliencePipelineExtensionsTests.cs | 78 +++++++++++++++ .../ResiliencePipelineExtensions.cs | 94 ++++++++++--------- .../Resilience/HttpResilienceOptions.cs | 4 + 4 files changed, 154 insertions(+), 57 deletions(-) diff --git a/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs b/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs index 3a96b92ef..3ef33290c 100644 --- a/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs +++ b/backend/CPS.ComplexCases.API/Extensions/IServiceCollectionExtension.cs @@ -1,11 +1,11 @@ -using System.Net; using CPS.ComplexCases.API.Clients.FileTransfer; using CPS.ComplexCases.API.Domain.Configuration; +using CPS.ComplexCases.Common.Extensions; +using CPS.ComplexCases.Common.Resilience; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Polly; namespace CPS.ComplexCases.API.Extensions; @@ -28,20 +28,27 @@ public static void AddFileTransferClient(this IServiceCollection services, IConf .AddResilienceHandler("file-transfer-retry", (pipeline, context) => { var options = context.ServiceProvider.GetRequiredService>().Value; - var retryAttempts = options.RetryAttempts > 0 ? options.RetryAttempts : 2; - var firstRetryDelaySeconds = options.FirstRetryDelaySeconds > 0 ? options.FirstRetryDelaySeconds : 1; + var logger = context.ServiceProvider + .GetRequiredService() + .CreateLogger("CPS.ComplexCases.API.FileTransfer"); - pipeline.AddRetry(new HttpRetryStrategyOptions + // Shared helper keeps POST/PUT out of status-code retries. Circuit breaker is off — + // FileTransfer only needs the common retry policy, not fail-fast shedding. + pipeline.AddStandardHttpResilience(new HttpResilienceOptions { - MaxRetryAttempts = retryAttempts, - Delay = TimeSpan.FromSeconds(firstRetryDelaySeconds), - BackoffType = DelayBackoffType.Exponential, - UseJitter = true, - ShouldHandle = static args => - ValueTask.FromResult(args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError) - }); + ServiceName = "FileTransfer", + RetryAttempts = options.RetryAttempts > 0 ? options.RetryAttempts : 2, + FirstRetryDelay = TimeSpan.FromSeconds( + options.FirstRetryDelaySeconds > 0 ? options.FirstRetryDelaySeconds : 1), + CircuitBreakerFailureThreshold = 0.5, + CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(30), + CircuitBreakerMinimumThroughput = 10, + CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(30), + EnableCircuitBreaker = false, + ConcurrencyLimit = 0, + }, logger); }); services.AddTransient(); } -} \ No newline at end of file +} diff --git a/backend/CPS.ComplexCases.Common.Tests/Extensions/ResiliencePipelineExtensionsTests.cs b/backend/CPS.ComplexCases.Common.Tests/Extensions/ResiliencePipelineExtensionsTests.cs index 36ab373b5..d70e9d080 100644 --- a/backend/CPS.ComplexCases.Common.Tests/Extensions/ResiliencePipelineExtensionsTests.cs +++ b/backend/CPS.ComplexCases.Common.Tests/Extensions/ResiliencePipelineExtensionsTests.cs @@ -48,6 +48,31 @@ await Assert.ThrowsAnyAsync(() => pipeline.ExecuteAsync(_ => Respond(HttpStatusCode.InternalServerError)).AsTask()); } + [Fact] + public async Task CircuitBreaker_WhenDisabled_DoesNotOpenAfterRepeatedServerErrors() + { + var pipeline = new ResiliencePipelineBuilder() + .AddStandardHttpResilience(new HttpResilienceOptions + { + ServiceName = "Test", + RetryAttempts = 0, + FirstRetryDelay = TimeSpan.FromMilliseconds(1), + CircuitBreakerFailureThreshold = 0.5, + CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(10), + CircuitBreakerMinimumThroughput = MinimumThroughput, + CircuitBreakerDurationOfBreak = TimeSpan.FromMilliseconds(500), + EnableCircuitBreaker = false, + ConcurrencyLimit = 0, + }, NullLogger.Instance) + .Build(); + + for (var i = 0; i < MinimumThroughput * 2; i++) + { + var response = await pipeline.ExecuteAsync(_ => Respond(HttpStatusCode.InternalServerError)); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + } + [Fact] public async Task CircuitBreaker_WhenOpen_FailsFastWithoutInvokingDelegate() { @@ -167,4 +192,57 @@ await pipeline.ExecuteAsync(_ => Assert.Equal(1, attempts); } + + // Regression: concurrency limiter must sit inside retry so a permit is released during backoff. + // With an outermost limiter (ConcurrencyLimit = 1), the concurrent call could not finish until + // the retrying call's blocked second attempt was released — WhenAny would time out. + [Fact] + public async Task ConcurrencyLimiter_ReleasesPermitDuringRetryBackoff() + { + var pipeline = new ResiliencePipelineBuilder() + .AddStandardHttpResilience(new HttpResilienceOptions + { + ServiceName = "Test", + RetryAttempts = 1, + // Long enough that the concurrent call is asserted while still in backoff, not on attempt 2. + FirstRetryDelay = TimeSpan.FromSeconds(2), + CircuitBreakerFailureThreshold = 0.5, + CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(30), + CircuitBreakerMinimumThroughput = 100, + CircuitBreakerDurationOfBreak = TimeSpan.FromSeconds(30), + ConcurrencyLimit = 1, + }, NullLogger.Instance) + .Build(); + + var firstAttemptReturned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowRetryAttempt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var attempts = 0; + + var retryingCall = pipeline.ExecuteAsync(async _ => + { + if (Interlocked.Increment(ref attempts) == 1) + { + firstAttemptReturned.SetResult(); + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test"); + return new HttpResponseMessage(HttpStatusCode.InternalServerError) { RequestMessage = request }; + } + + await allowRetryAttempt.Task; + return new HttpResponseMessage(HttpStatusCode.OK); + }).AsTask(); + + await firstAttemptReturned.Task; + // Let the pipeline release the innermost permit and enter retry backoff. + await Task.Delay(50); + + var concurrentCallTask = pipeline.ExecuteAsync(_ => + ValueTask.FromResult(new HttpResponseMessage(HttpStatusCode.OK))).AsTask(); + + var completed = await Task.WhenAny(concurrentCallTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(concurrentCallTask, completed); + Assert.Equal(HttpStatusCode.OK, (await concurrentCallTask).StatusCode); + + allowRetryAttempt.SetResult(); + await retryingCall; + } } diff --git a/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs b/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs index 4a6d3237e..3aa1afbf2 100644 --- a/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs +++ b/backend/CPS.ComplexCases.Common/Extensions/ResiliencePipelineExtensions.cs @@ -24,20 +24,19 @@ public static Action, ResilienceH }; // Configures a standard HTTP resilience pipeline built on Microsoft.Extensions.Http.Resilience - // (Polly v8). Strategies are added outer-to-inner: concurrency limiter, then retry, then circuit - // breaker. Retry sits outside the breaker so a retry attempt re-enters the (possibly open) circuit - // and fails fast instead of bypassing it, matching the Microsoft standard resilience ordering. + // (Polly v8). Strategies are added outer-to-inner: retry, then optional circuit breaker, then + // concurrency limiter. Retry sits outside the breaker so a retry attempt re-enters the (possibly + // open) circuit and fails fast instead of bypassing it. The concurrency limiter sits innermost so + // a permit is held only for a single attempt and released during retry backoff — matching the + // previous Polly v7 WrapAsync(retry, breaker, bulkhead) ordering. An outermost limiter with + // queueLimit: int.MaxValue would hold permits across the full retry+backoff window and stall + // throughput under a burst of transient 5xx. Set EnableCircuitBreaker = false when only the + // shared retry (and optional concurrency) semantics are required. public static ResiliencePipelineBuilder AddStandardHttpResilience( this ResiliencePipelineBuilder pipeline, HttpResilienceOptions options, ILogger logger) { - // Concurrency limiter outermost: shed excess load before doing any work. - if (options.ConcurrencyLimit > 0) - { - pipeline.AddConcurrencyLimiter(permitLimit: options.ConcurrencyLimit, queueLimit: int.MaxValue); - } - // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience if (options.RetryAttempts > 0) { @@ -72,47 +71,56 @@ public static ResiliencePipelineBuilder AddStandardHttpResi // Only "service is down" signals (5xx and connection failures) trip the breaker. Non-health // signals such as 404 or 429 are deliberately excluded even when retry handles them. - pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions + if (options.EnableCircuitBreaker) { - FailureRatio = options.CircuitBreakerFailureThreshold, - SamplingDuration = options.CircuitBreakerSamplingDuration, - MinimumThroughput = options.CircuitBreakerMinimumThroughput, - BreakDuration = options.CircuitBreakerDurationOfBreak, - ShouldHandle = args => + pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { - if (args.Outcome.Exception is HttpRequestException) + FailureRatio = options.CircuitBreakerFailureThreshold, + SamplingDuration = options.CircuitBreakerSamplingDuration, + MinimumThroughput = options.CircuitBreakerMinimumThroughput, + BreakDuration = options.CircuitBreakerDurationOfBreak, + ShouldHandle = args => { - return ValueTask.FromResult(true); - } + if (args.Outcome.Exception is HttpRequestException) + { + return ValueTask.FromResult(true); + } + + if (args.Outcome.Result is null) + { + return ValueTask.FromResult(false); + } - if (args.Outcome.Result is null) + return ValueTask.FromResult(args.Outcome.Result.StatusCode >= HttpStatusCode.InternalServerError); + }, + OnOpened = args => + { + logger.LogError( + args.Outcome.Exception, + "{ServiceName} circuit opened for {BreakDelaySeconds}s after status {StatusCode}.", + options.ServiceName, + args.BreakDuration.TotalSeconds, + args.Outcome.Result?.StatusCode); + return default; + }, + OnClosed = _ => + { + logger.LogInformation("{ServiceName} circuit reset; calls are flowing again.", options.ServiceName); + return default; + }, + OnHalfOpened = _ => { - return ValueTask.FromResult(false); + logger.LogInformation("{ServiceName} circuit half-open; testing the next call.", options.ServiceName); + return default; } + }); + } - return ValueTask.FromResult(args.Outcome.Result.StatusCode >= HttpStatusCode.InternalServerError); - }, - OnOpened = args => - { - logger.LogError( - args.Outcome.Exception, - "{ServiceName} circuit opened for {BreakDelaySeconds}s after status {StatusCode}.", - options.ServiceName, - args.BreakDuration.TotalSeconds, - args.Outcome.Result?.StatusCode); - return default; - }, - OnClosed = _ => - { - logger.LogInformation("{ServiceName} circuit reset; calls are flowing again.", options.ServiceName); - return default; - }, - OnHalfOpened = _ => - { - logger.LogInformation("{ServiceName} circuit half-open; testing the next call.", options.ServiceName); - return default; - } - }); + // Innermost: permit held only during the attempt itself, freed while waiting to retry. + if (options.ConcurrencyLimit > 0) + { + pipeline.AddConcurrencyLimiter(permitLimit: options.ConcurrencyLimit, queueLimit: int.MaxValue); + } return pipeline; } diff --git a/backend/CPS.ComplexCases.Common/Resilience/HttpResilienceOptions.cs b/backend/CPS.ComplexCases.Common/Resilience/HttpResilienceOptions.cs index 27d7f8eef..e156298d5 100644 --- a/backend/CPS.ComplexCases.Common/Resilience/HttpResilienceOptions.cs +++ b/backend/CPS.ComplexCases.Common/Resilience/HttpResilienceOptions.cs @@ -12,6 +12,10 @@ public sealed record HttpResilienceOptions public required int CircuitBreakerMinimumThroughput { get; init; } public required TimeSpan CircuitBreakerDurationOfBreak { get; init; } + // When false, the circuit breaker is omitted. Used by clients that only need the shared retry + // (and optional concurrency) semantics — e.g. FileTransfer — without fail-fast shedding. + public bool EnableCircuitBreaker { get; init; } = true; + // Maximum number of concurrent requests allowed through to the service. Set to 0 to disable the // concurrency limiter (e.g. for low-volume request/response services). public int ConcurrencyLimit { get; init; }