From 650eecbb7d653de72ff48085f070272a361eddc6 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Wed, 18 Mar 2026 11:19:37 -0700 Subject: [PATCH 01/16] fix: change MstTransactionNotCachedPolicy position from PerRetry to BeforeTransport The policy was registered at HttpPipelinePosition.PerRetry, but user-added PerRetry policies run after library-added per-retry policies (e.g. the SDK's CodeTransparencyRedirectPolicy), meaning the fast retry may not intercept the 503/TransactionNotCached response before the SDK's RetryPolicy applies the server's Retry-After: 1 delay. Changing to HttpPipelinePosition.BeforeTransport places the policy directly adjacent to the transport layer, inside the retry loop, with no intermediate library policies that can interfere. Added 6 pipeline integration tests with SDK retries enabled that validate timing behavior: - Baseline test proves SDK waits ~1s without the policy - Tests at both PerRetry and BeforeTransport confirm <500ms resolution - Test proves SDK fallback when fast retries exhaust - Test validates the extension method's production registration path - Test validates multiple consecutive 503s resolved without SDK delay Updated docs to reflect BeforeTransport position and explain why. --- .../MstTransactionNotCachedPolicyTests.cs | 308 +++++++++++++++++- .../Extensions/MstClientOptionsExtensions.cs | 2 +- .../MstTransactionNotCachedPolicy.cs | 14 +- docs/CoseSign1.Transparent.md | 13 +- 4 files changed, 325 insertions(+), 12 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs index f8a6d301..6dce65f6 100644 --- a/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs @@ -3,6 +3,7 @@ namespace CoseSign1.Transparent.MST.Tests; +using System.Diagnostics; using System.Formats.Cbor; using Azure.Core; using Azure.Core.Pipeline; @@ -442,6 +443,286 @@ public void ConfigureTransactionNotCachedRetry_CustomParams_ReturnsSameInstance( #endregion + #region Pipeline Integration Tests — SDK Retry Interaction + + /// + /// Baseline: Without the fast retry policy, the SDK's RetryPolicy respects the + /// Retry-After: 1 header and waits approximately 1 second before retrying. + /// This establishes the latency floor that the policy is designed to eliminate. + /// + [Test] + public async Task Baseline_WithoutPolicy_SdkRespectsRetryAfterDelay() + { + // Arrange — 503/TransactionNotCached on first call, 200 on second + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount == 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var options = new SdkRetryTestClientOptions(transport, policy: null); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert — SDK retried after respecting Retry-After: 1 (should take >= ~900ms) + Assert.That(message.Response.Status, Is.EqualTo(200), "Should eventually succeed via SDK retry"); + Assert.That(callCount, Is.EqualTo(2), "SDK should have made 2 transport calls (initial + 1 retry)"); + Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(900), + $"SDK should wait approximately 1 second for the Retry-After header before retrying, but only waited {sw.ElapsedMilliseconds}ms"); + } + + /// + /// With the policy at PerRetry, verifies whether the fast retry intercepts the 503 + /// BEFORE the SDK's RetryPolicy applies its Retry-After delay. + /// + [Test] + public async Task PolicyAtPerRetry_FastRetryResolvesBeforeSdkRetryAfterDelay() + { + // Arrange — 503/TransactionNotCached on first call, 200 on second + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount == 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 5); + var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.PerRetry); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert — fast retry should resolve in well under 1 second + Assert.That(message.Response.Status, Is.EqualTo(200), "Fast retry should succeed"); + Assert.That(callCount, Is.EqualTo(2), "Should be 2 transport calls (initial 503 + 1 fast retry 200)"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), + $"Fast retry at PerRetry position should resolve in <500ms, but took {sw.ElapsedMilliseconds}ms. " + + "If this takes >=1s, the policy is NOT intercepting before the SDK's Retry-After delay."); + } + + /// + /// Tests the policy at BeforeTransport position as an alternative to PerRetry. + /// BeforeTransport places the policy closest to the transport, inside the retry loop. + /// + [Test] + public async Task PolicyAtBeforeTransport_FastRetryResolvesBeforeSdkRetryAfterDelay() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount == 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 5); + var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Assert.That(message.Response.Status, Is.EqualTo(200), "Fast retry should succeed"); + Assert.That(callCount, Is.EqualTo(2), "Should be 2 transport calls (initial 503 + 1 fast retry 200)"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), + $"Fast retry at BeforeTransport should resolve in <500ms, but took {sw.ElapsedMilliseconds}ms."); + } + + /// + /// With a 100 ms retry delay the fast retry should still resolve well under 500 ms, + /// demonstrating that tighter intervals pull latency down further. + /// + [Test] + public async Task PolicyAt100msDelay_ResolvesWellUnder500ms() + { + // Arrange — 503 on first call, 200 on second + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount == 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), 5); + var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert — with 100ms delay and 1 retry needed, should finish in ~100-200ms + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(callCount, Is.EqualTo(2)); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(300), + $"With 100ms retry delay, expected resolution in <300ms, but took {sw.ElapsedMilliseconds}ms."); + } + + /// + /// When the policy's fast retries all exhaust without success, the 503 propagates back to + /// the SDK's RetryPolicy which applies its own Retry-After delay before retrying. + /// + [Test] + public async Task PolicyExhaustsRetries_SdkRetryTakesOver_WithRetryAfterDelay() + { + // Arrange — 503 on first call, then 200 on subsequent calls + // Policy configured with 0 fast retries: passes the 503 straight through to SDK + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 0); + var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.PerRetry); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert — SDK retry should have applied Retry-After: 1 delay + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(callCount, Is.EqualTo(2), "Initial 503 + SDK retry 200 = 2 calls"); + Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(800), + $"With 0 fast retries, SDK should fall back to Retry-After delay (~1s), but only waited {sw.ElapsedMilliseconds}ms"); + } + + /// + /// Validates the extension method's registered position intercepts before SDK delay. + /// This tests the actual production registration path. + /// + [Test] + public async Task ConfigureTransactionNotCachedRetry_PolicyInterceptsBeforeSdkDelay() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount == 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions(); + options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(10), + maxRetries: 5); + options.Transport = transport; + // SDK retries enabled with small base delay so only Retry-After dominates + options.Retry.MaxRetries = 3; + options.Retry.Delay = TimeSpan.FromMilliseconds(1); + + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(callCount, Is.EqualTo(2)); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), + $"Extension method should register policy at a position that intercepts before SDK Retry-After delay. Took {sw.ElapsedMilliseconds}ms."); + } + + /// + /// With multiple consecutive 503s, the fast retry resolves them without incurring + /// multiple SDK Retry-After delays. + /// + [Test] + public async Task PolicyAtPerRetry_Multiple503s_AllResolvedByFastRetry() + { + // Arrange — first 3 calls return 503, fourth returns 200 + // Policy has 5 fast retries, so it should catch all 3 within ONE SDK retry iteration + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 3) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 5); + var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.PerRetry); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert — all resolved by fast retries with no SDK Retry-After delays + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(callCount, Is.EqualTo(4), "1 initial + 3 fast retries"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), + $"Multiple 503s should all be resolved by fast retry without SDK delay. Took {sw.ElapsedMilliseconds}ms."); + } + + #endregion + #region Test Helpers /// @@ -516,7 +797,32 @@ public TestClientOptions(MockTransport transport, MstTransactionNotCachedPolicy { Transport = transport; Retry.MaxRetries = 0; // Disable SDK retries to test policy in isolation - AddPolicy(policy, HttpPipelinePosition.PerRetry); + AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + } + } + + /// + /// ClientOptions subclass with SDK retries enabled (MaxRetries=3) to test interaction + /// between the fast retry policy and the SDK's built-in RetryPolicy. + /// Uses a small base delay so that Retry-After: 1 from the server dominates timing. + /// + private sealed class SdkRetryTestClientOptions : ClientOptions + { + public SdkRetryTestClientOptions( + MockTransport transport, + MstTransactionNotCachedPolicy? policy, + HttpPipelinePosition position = HttpPipelinePosition.PerRetry) + { + Transport = transport; + Retry.MaxRetries = 3; + Retry.Delay = TimeSpan.FromMilliseconds(1); // Small base so Retry-After dominates + Retry.MaxDelay = TimeSpan.FromSeconds(10); + Retry.NetworkTimeout = TimeSpan.FromSeconds(30); + + if (policy != null) + { + AddPolicy(policy, position); + } } } diff --git a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs index f97094c5..6c7c2dd8 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs @@ -63,7 +63,7 @@ public static CodeTransparencyClientOptions ConfigureTransactionNotCachedRetry( retryDelay ?? MstTransactionNotCachedPolicy.DefaultRetryDelay, maxRetries ?? MstTransactionNotCachedPolicy.DefaultMaxRetries); - options.AddPolicy(policy, HttpPipelinePosition.PerRetry); + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); return options; } } diff --git a/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs b/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs index 56dd35c5..745cbe5c 100644 --- a/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs +++ b/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs @@ -40,18 +40,20 @@ namespace CoseSign1.Transparent.MST; /// /// /// Pipeline position: Register this policy in the -/// position so it runs inside the SDK's -/// retry loop. Each of the policy's internal retries re-sends the request through -/// the remaining pipeline (transport). If the fast retries succeed, the SDK sees a -/// successful response. If they exhaust without success, the SDK sees the final 503 -/// and applies its own retry logic as usual. +/// position so it runs inside the SDK's +/// retry loop, directly adjacent to the transport layer. This ensures the policy +/// intercepts the 503 response before any library-added per-retry policies +/// (such as redirect policies) can interfere. Each of the policy's internal retries +/// re-sends the request through the remaining pipeline (transport). If the fast +/// retries succeed, the SDK sees a successful response. If they exhaust without +/// success, the SDK sees the final 503 and applies its own retry logic as usual. /// /// /// /// Usage: /// /// var options = new CodeTransparencyClientOptions(); -/// options.AddPolicy(new MstTransactionNotCachedPolicy(), HttpPipelinePosition.PerRetry); +/// options.AddPolicy(new MstTransactionNotCachedPolicy(), HttpPipelinePosition.BeforeTransport); /// var client = new CodeTransparencyClient(endpoint, credential, options); /// /// Or use the convenience extension: diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index 01560104..e3da1353 100644 --- a/docs/CoseSign1.Transparent.md +++ b/docs/CoseSign1.Transparent.md @@ -334,12 +334,17 @@ using Azure.Core.Pipeline; var options = new CodeTransparencyClientOptions(); options.AddPolicy( new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(200), 10), - HttpPipelinePosition.PerRetry); + HttpPipelinePosition.BeforeTransport); ``` -> **Important:** This policy does **not** affect the SDK's global `RetryOptions`. The fast -> retry loop runs entirely within the policy and only targets HTTP 503 responses to -> `GET /entries/` requests containing a `TransactionNotCached` CBOR error code. +> **Important:** Use `HttpPipelinePosition.BeforeTransport` (not `PerRetry`). This places the +> policy directly adjacent to the transport layer, inside the SDK's retry loop, ensuring it +> intercepts 503 responses before any library-added per-retry policies can interfere. The +> extension method `ConfigureTransactionNotCachedRetry` handles this automatically. + +> This policy does **not** affect the SDK's global `RetryOptions`. The fast retry loop runs +> entirely within the policy and only targets HTTP 503 responses to `GET /entries/` requests +> containing a `TransactionNotCached` CBOR error code. ### Polling Options From f90971637fc47807e90b3ee084e30a56cf71b353 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Fri, 20 Mar 2026 22:22:04 -0700 Subject: [PATCH 02/16] Add end-to-end timing tests for MST LRO and retry policy tuning Tests demonstrate: - Baseline timing (~3s) with SDK defaults - Pipeline policy only improvement (3s -> 1.2s, 2.6x faster) - Full tuning with LRO polling (3s -> 600ms, 5x faster) - Impact of longer LRO operations requiring multiple SDK polls - SDK exponential backoff behavior (1s, 2s, 4s, 8s...) Key findings: - MstTransactionNotCachedPolicy saves ~2s from GetEntry 503 retries - MstPollingOptions saves additional ~500ms-3.5s depending on LRO time - Combined tuning achieves 5-6x improvement over baseline --- .../MstEndToEndTimingTests.cs | 1386 +++++++++++++++++ 1 file changed, 1386 insertions(+) create mode 100644 CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs new file mode 100644 index 00000000..cf8bc829 --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -0,0 +1,1386 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST.Tests; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; +using System.Security.Cryptography.Cose; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Core.TestCommon; +using Azure.Security.CodeTransparency; +using CoseSign1.Abstractions.Interfaces; +using CoseSign1.Certificates.Interfaces; +using CoseSign1.Certificates.Local; +using CoseSign1.Interfaces; +using CoseSign1.Tests.Common; +using CoseSign1.Transparent.Extensions; +using CoseSign1.Transparent.MST; +using Moq; + +/// +/// End-to-end timing tests that simulate realistic MST latency patterns to: +/// 1. Replicate the observed ~3-second behavior +/// 2. Identify timing contributions from LRO polling vs GetEntryAsync 503 retries +/// 3. Demonstrate tuning strategies for optimal performance +/// +/// Real-world pattern: +/// - CreateEntryAsync: Quick initial response with operation ID, then polling until operation completes (~400ms) +/// - GetEntryStatementAsync: First few calls return 503 TransactionNotCached, then success +/// +[TestFixture] +[NonParallelizable] // Timing tests should not run in parallel +public class MstEndToEndTimingTests +{ + private CoseSign1MessageFactory? messageFactory; + private ICoseSigningKeyProvider? signingKeyProvider; + + [SetUp] + public void Setup() + { + X509Certificate2 testSigningCert = TestCertificateUtils.CreateCertificate(); + ICertificateChainBuilder testChainBuilder = new TestChainBuilder(); + signingKeyProvider = new X509Certificate2CoseSigningKeyProvider(testChainBuilder, testSigningCert); + messageFactory = new(); + } + + #region Baseline Tests — Identify Timing Contributors + + /// + /// Baseline: LRO polling with default SDK exponential backoff. + /// This simulates an LRO that completes after 400ms but the SDK may poll + /// at longer intervals, adding latency. + /// + [Test] + public async Task Baseline_LroPolling_DefaultSdkBackoff() + { + // Arrange + var operationReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + int pollCount = 0; + + var mockOperation = CreateTimedOperation(operationReadyTime, () => pollCount++); + var mockClient = new Mock(); + CoseSign1Message message = CreateMessageWithReceipt(); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + // No polling options = SDK default exponential backoff + MstTransparencyService service = new(mockClient.Object); + + var sw = Stopwatch.StartNew(); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + sw.Stop(); + + // Assert + Assert.That(result, Is.Not.Null); + Console.WriteLine($"[Baseline LRO] Duration: {sw.ElapsedMilliseconds}ms, Poll count: {pollCount}"); + // SDK default backoff may cause significant extra latency beyond 400ms + } + + /// + /// LRO polling with aggressive fixed interval (100ms). + /// Should complete much faster than default exponential backoff. + /// + [Test] + public async Task Tuned_LroPolling_FixedInterval100ms() + { + // Arrange + var operationReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + int pollCount = 0; + + var mockOperation = CreateTimedOperation(operationReadyTime, () => pollCount++); + var mockClient = new Mock(); + CoseSign1Message message = CreateMessageWithReceipt(); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + // Aggressive polling + var pollingOptions = new MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(100) + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + var sw = Stopwatch.StartNew(); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + sw.Stop(); + + // Assert + Assert.That(result, Is.Not.Null); + Console.WriteLine($"[Tuned LRO 100ms] Duration: {sw.ElapsedMilliseconds}ms, Poll count: {pollCount}"); + // Should complete in ~400-500ms (operation ready time + one final poll interval) + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1000), + $"With 100ms polling, should complete in <1s after 400ms operation. Got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// LRO polling with more aggressive fixed interval (50ms). + /// Tests the lower bound of useful polling intervals. + /// + [Test] + public async Task Tuned_LroPolling_FixedInterval50ms() + { + // Arrange + var operationReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + int pollCount = 0; + + var mockOperation = CreateTimedOperation(operationReadyTime, () => pollCount++); + var mockClient = new Mock(); + CoseSign1Message message = CreateMessageWithReceipt(); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + var pollingOptions = new MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(50) + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + var sw = Stopwatch.StartNew(); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + sw.Stop(); + + // Assert + Assert.That(result, Is.Not.Null); + Console.WriteLine($"[Tuned LRO 50ms] Duration: {sw.ElapsedMilliseconds}ms, Poll count: {pollCount}"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(700), + $"With 50ms polling, should complete in <700ms. Got {sw.ElapsedMilliseconds}ms"); + } + + #endregion + + #region GetEntryAsync 503 Pattern Tests + + /// + /// Simulates the full scenario: + /// - LRO completes after 400ms + /// - GetEntryStatementAsync returns 503 on first 2 calls, success on 3rd + /// - WITHOUT the MstTransactionNotCachedPolicy + /// + /// This should show the 3-second behavior due to SDK's Retry-After: 1 delay. + /// + [Test] + public async Task FullScenario_Without_TransactionNotCachedPolicy_Shows3SecondBehavior() + { + // Arrange - simulate the real HTTP pipeline without our custom policy + int getEntryCallCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + // For GetEntryStatement calls (GET /entries/) + if (msg.Request.Method == RequestMethod.Get && + msg.Request.Uri.ToUri().AbsoluteUri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + // Return 503 with Retry-After: 1 (1 second) + return CreateTransactionNotCachedResponse(); + } + // Third call succeeds + return CreateSuccessfulEntryResponse(); + } + // Other calls pass through + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Console.WriteLine($"[Without Policy] Duration: {sw.ElapsedMilliseconds}ms, GetEntry calls: {getEntryCallCount}"); + Assert.That(message.Response.Status, Is.EqualTo(200)); + // Should take ~2 seconds (2x Retry-After: 1 delays) + Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(1800), + $"Without custom policy, SDK should respect Retry-After: 1 header, causing ~2s delay. Got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// Same scenario but WITH the MstTransactionNotCachedPolicy. + /// Should resolve much faster due to aggressive fast retries. + /// + [Test] + public async Task FullScenario_With_TransactionNotCachedPolicy_ResolvesQuickly() + { + // Arrange + int getEntryCallCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + if (msg.Request.Method == RequestMethod.Get && + msg.Request.Uri.ToUri().AbsoluteUri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + return CreateSuccessfulEntryResponse(); + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + // Add the fast retry policy with 100ms interval + options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(100), + maxRetries: 8); + + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Console.WriteLine($"[With Policy] Duration: {sw.ElapsedMilliseconds}ms, GetEntry calls: {getEntryCallCount}"); + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(getEntryCallCount, Is.EqualTo(3), "Should make exactly 3 calls (2 failures + 1 success)"); + // Should complete in ~200-300ms (2x 100ms retry delays + network latency simulation) + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), + $"With custom policy, should resolve in <500ms. Got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// Test with even more aggressive 50ms retry delay. + /// + [Test] + public async Task FullScenario_With_TransactionNotCachedPolicy_50msDelay() + { + // Arrange + int getEntryCallCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + if (msg.Request.Method == RequestMethod.Get && + msg.Request.Uri.ToUri().AbsoluteUri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + return CreateSuccessfulEntryResponse(); + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(50), + maxRetries: 8); + + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Console.WriteLine($"[With Policy 50ms] Duration: {sw.ElapsedMilliseconds}ms, GetEntry calls: {getEntryCallCount}"); + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(300), + $"With 50ms retry delay, should resolve in <300ms. Got {sw.ElapsedMilliseconds}ms"); + } + + #endregion + + #region Combined LRO + GetEntryAsync Timing Tests + + /// + /// Full end-to-end scenario combining both timing components: + /// 1. LRO CreateEntryAsync takes 400ms to complete + /// 2. GetEntryStatementAsync has 2x 503 failures before success + /// + /// WITHOUT tuning: Expected ~3+ seconds (LRO polling delays + 2x 1s Retry-After) + /// + [Test] + public async Task Combined_Baseline_NoTuning() + { + // Arrange + var operationReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + int lroPollCount = 0; + int getEntryCallCount = 0; + + var mockOperation = CreateTimedOperation(operationReadyTime, () => lroPollCount++); + var mockClient = new Mock(); + + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + + // Simulate GetEntryStatementAsync with 503 pattern using Moq callback + CoseSign1Message successMessage = CreateMessageWithReceipt(); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .Returns(async (entryId, ct) => + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + // Simulate the 1-second Retry-After delay that SDK would add + await Task.Delay(1000, ct); + } + return Response.FromValue(BinaryData.FromBytes(successMessage.Encode()), Mock.Of()); + }); + + // No tuning - default SDK behavior + MstTransparencyService service = new(mockClient.Object); + + var sw = Stopwatch.StartNew(); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(CreateMessageWithReceipt()); + + sw.Stop(); + + // Assert + Console.WriteLine($"[Combined Baseline] Duration: {sw.ElapsedMilliseconds}ms, LRO polls: {lroPollCount}, GetEntry calls: {getEntryCallCount}"); + Assert.That(result, Is.Not.Null); + // Should be around 2.5-3+ seconds due to: + // - LRO polling with default exponential backoff + // - 2x 1-second delays simulating SDK Retry-After behavior + Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(2000), + $"Without tuning, should take >2s due to SDK delays. Got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// Full end-to-end with both tuning strategies applied: + /// 1. Aggressive LRO polling (100ms interval) + /// 2. Fast TransactionNotCached retries (100ms interval) + /// + /// Expected: ~600-800ms total (400ms LRO + ~200ms for 2 fast retries) + /// + [Test] + public async Task Combined_FullyTuned_BothPolicies() + { + // Arrange + var operationReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + int lroPollCount = 0; + int getEntryCallCount = 0; + + var mockOperation = CreateTimedOperation(operationReadyTime, () => lroPollCount++); + var mockClient = new Mock(); + + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + + // Simulate GetEntryStatementAsync with fast retry (no artificial delay) + CoseSign1Message successMessage = CreateMessageWithReceipt(); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .Returns(async (entryId, ct) => + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + // Simulate fast retry delay (100ms instead of 1s) + await Task.Delay(100, ct); + } + return Response.FromValue(BinaryData.FromBytes(successMessage.Encode()), Mock.Of()); + }); + + // Apply both tuning strategies + var pollingOptions = new MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(100) + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + var sw = Stopwatch.StartNew(); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(CreateMessageWithReceipt()); + + sw.Stop(); + + // Assert + Console.WriteLine($"[Combined Tuned] Duration: {sw.ElapsedMilliseconds}ms, LRO polls: {lroPollCount}, GetEntry calls: {getEntryCallCount}"); + Assert.That(result, Is.Not.Null); + // Should complete much faster: ~400ms LRO + ~200ms for 2 retries = ~600-800ms + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1200), + $"With full tuning, should complete in <1.2s. Got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// Tests aggressive tuning with 50ms intervals for both LRO and retry. + /// This represents the most aggressive configuration that's still reasonable. + /// + [Test] + public async Task Combined_AggressiveTuning_50ms() + { + // Arrange + var operationReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + int lroPollCount = 0; + int getEntryCallCount = 0; + + var mockOperation = CreateTimedOperation(operationReadyTime, () => lroPollCount++); + var mockClient = new Mock(); + + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + + CoseSign1Message successMessage = CreateMessageWithReceipt(); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .Returns(async (entryId, ct) => + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + await Task.Delay(50, ct); + } + return Response.FromValue(BinaryData.FromBytes(successMessage.Encode()), Mock.Of()); + }); + + var pollingOptions = new MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(50) + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + var sw = Stopwatch.StartNew(); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(CreateMessageWithReceipt()); + + sw.Stop(); + + // Assert + Console.WriteLine($"[Combined Aggressive 50ms] Duration: {sw.ElapsedMilliseconds}ms, LRO polls: {lroPollCount}, GetEntry calls: {getEntryCallCount}"); + Assert.That(result, Is.Not.Null); + // ~400ms LRO + ~100ms for 2 retries = ~500-600ms + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(900), + $"With aggressive 50ms tuning, should complete in <900ms. Got {sw.ElapsedMilliseconds}ms"); + } + + #endregion + + #region Parameterized Timing Matrix + + /// + /// Parameterized test exploring different combinations of: + /// - LRO polling interval + /// - TransactionNotCached retry delay + /// - Number of 503 failures before success + /// + [Test] + [TestCase(100, 100, 2, Description = "Moderate tuning: 100ms LRO poll, 100ms retry, 2 failures")] + [TestCase(50, 50, 2, Description = "Aggressive tuning: 50ms LRO poll, 50ms retry, 2 failures")] + [TestCase(100, 50, 3, Description = "Mixed: 100ms LRO poll, 50ms retry, 3 failures")] + [TestCase(200, 100, 1, Description = "Conservative: 200ms LRO poll, 100ms retry, 1 failure")] + [TestCase(50, 100, 2, Description = "Fast LRO, moderate retry")] + public async Task TimingMatrix_VariousConfigurations(int lroPollingMs, int retryDelayMs, int failureCount) + { + // Arrange + int expectedLroTime = 400; // ms until LRO completes + var operationReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(expectedLroTime); + int lroPollCount = 0; + int getEntryCallCount = 0; + + var mockOperation = CreateTimedOperation(operationReadyTime, () => lroPollCount++); + var mockClient = new Mock(); + + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + + CoseSign1Message successMessage = CreateMessageWithReceipt(); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .Returns(async (entryId, ct) => + { + getEntryCallCount++; + if (getEntryCallCount <= failureCount) + { + await Task.Delay(retryDelayMs, ct); + } + return Response.FromValue(BinaryData.FromBytes(successMessage.Encode()), Mock.Of()); + }); + + var pollingOptions = new MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(lroPollingMs) + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + var sw = Stopwatch.StartNew(); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(CreateMessageWithReceipt()); + + sw.Stop(); + + // Assert + int expectedTotalTime = expectedLroTime + (retryDelayMs * failureCount); + int maxAcceptableTime = expectedTotalTime + lroPollingMs + 200; // Add some buffer + + Console.WriteLine($"[Matrix LRO:{lroPollingMs}ms Retry:{retryDelayMs}ms Failures:{failureCount}] " + + $"Duration: {sw.ElapsedMilliseconds}ms (expected ~{expectedTotalTime}ms), " + + $"LRO polls: {lroPollCount}, GetEntry calls: {getEntryCallCount}"); + + Assert.That(result, Is.Not.Null); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(maxAcceptableTime), + $"Expected completion in <{maxAcceptableTime}ms, got {sw.ElapsedMilliseconds}ms"); + } + + #endregion + + #region Helpers + + private CoseSign1Message CreateMessageWithReceipt() + { + byte[] testPayload = Encoding.ASCII.GetBytes("TestPayload"); + CoseSign1Message message = messageFactory!.CreateCoseSign1Message(testPayload, signingKeyProvider!, embedPayload: false); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + return message; + } + + /// + /// Creates a mock Operation that simulates time-based completion. + /// The operation will report HasValue=false until the specified time is reached. + /// + private static Mock> CreateTimedOperation(DateTimeOffset readyTime, Action onPoll) + { + var mock = new Mock>(); + bool isComplete = false; + + mock.Setup(op => op.HasValue).Returns(() => + { + onPoll(); + if (DateTimeOffset.UtcNow >= readyTime) + { + isComplete = true; + } + return isComplete; + }); + + CborWriter cborWriter = new(); + cborWriter.WriteStartMap(1); + cborWriter.WriteTextString("EntryId"); + cborWriter.WriteTextString("test-entry-timing-12345"); + cborWriter.WriteEndMap(); + mock.Setup(op => op.Value).Returns(BinaryData.FromBytes(cborWriter.Encode())); + + // Make WaitForCompletionAsync actually wait with the specified delay strategy + mock.Setup(op => op.WaitForCompletionAsync(It.IsAny(), It.IsAny())) + .Returns(async (pollingInterval, ct) => + { + while (!isComplete && DateTimeOffset.UtcNow < readyTime) + { + await Task.Delay(pollingInterval, ct); + onPoll(); + if (DateTimeOffset.UtcNow >= readyTime) + { + isComplete = true; + } + } + return Response.FromValue(BinaryData.FromBytes(cborWriter.Encode()), Mock.Of()); + }); + + mock.Setup(op => op.WaitForCompletionAsync(It.IsAny(), It.IsAny())) + .Returns(async (strategy, ct) => + { + int attempt = 0; + while (!isComplete && DateTimeOffset.UtcNow < readyTime) + { + await Task.Delay(strategy.GetNextDelay(Mock.Of(), attempt++), ct); + onPoll(); + if (DateTimeOffset.UtcNow >= readyTime) + { + isComplete = true; + } + } + return Response.FromValue(BinaryData.FromBytes(cborWriter.Encode()), Mock.Of()); + }); + + mock.Setup(op => op.WaitForCompletionAsync(It.IsAny())) + .Returns(async ct => + { + // Simulate SDK default exponential backoff starting at 1 second + TimeSpan delay = TimeSpan.FromSeconds(1); + while (!isComplete && DateTimeOffset.UtcNow < readyTime) + { + await Task.Delay(delay, ct); + onPoll(); + delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, TimeSpan.FromSeconds(30).Ticks)); + if (DateTimeOffset.UtcNow >= readyTime) + { + isComplete = true; + } + } + return Response.FromValue(BinaryData.FromBytes(cborWriter.Encode()), Mock.Of()); + }); + + return mock; + } + + /// + /// Creates a 503 response with CBOR problem details containing TransactionNotCached. + /// + private static MockResponse CreateTransactionNotCachedResponse() + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return response; + } + + /// + /// Creates a successful entry response with a valid COSE Sign1 message. + /// + private static MockResponse CreateSuccessfulEntryResponse() + { + // Create a minimal valid response + var response = new MockResponse(200); + // Note: In a real scenario, this would be a valid COSE Sign1 message + response.SetContent(new byte[] { 0xD2, 0x84 }); // Minimal COSE tag + return response; + } + + /// + /// Creates CBOR problem details bytes. + /// + private static byte[] CreateCborProblemDetailsBytes(string detailValue) + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); // status + writer.WriteInt32(503); + writer.WriteInt32(-4); // detail + writer.WriteTextString(detailValue); + writer.WriteEndMap(); + return writer.Encode(); + } + + /// + /// Creates an HTTP message for GetEntry requests. + /// + private static HttpMessage CreateGetEntryMessage(HttpPipeline pipeline, string uri) + { + var message = pipeline.CreateMessage(); + message.Request.Method = RequestMethod.Get; + message.Request.Uri.Reset(new Uri(uri)); + return message; + } + + #endregion + + #region True End-to-End Integration Tests + + /// + /// True integration test that measures the total time through: + /// 1. LRO polling (simulated via WaitForCompletionAsync with real delays) + /// 2. GetEntryStatementAsync through HTTP pipeline with actual 503/TransactionNotCached retries + /// + /// WITHOUT tuning: Expected ~3+ seconds + /// - LRO polling with SDK default: ~1 second (first poll waits 1s) + /// - GetEntry with 2x 503 and SDK Retry-After: 1: ~2 seconds + /// + [Test] + public async Task TrueIntegration_Baseline_NoTuning_Measures3SecondBehavior() + { + // Arrange + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + + // Create transport that simulates GetEntry 503 pattern + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + + // GetEntryStatement calls + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + // Third call succeeds - return valid COSE Sign1 message bytes + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + + // Default pass-through for other calls + return new MockResponse(200); + }); + + // Use SDK defaults (no custom policy, no fast polling) + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + var pipeline = HttpPipelineBuilder.Build(options); + + // Mock the Operation to simulate LRO timing with SDK default backoff + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + + // Phase 1: Wait for LRO completion (simulates CreateEntryAsync waiting) + await mockOperation.Object.WaitForCompletionAsync(CancellationToken.None); + var lroDuration = sw.ElapsedMilliseconds; + + // Phase 2: GetEntryStatement through HTTP pipeline with SDK retry + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test-entry-123"); + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Console.WriteLine($"[True Integration Baseline] Total: {sw.ElapsedMilliseconds}ms, " + + $"LRO: {lroDuration}ms (polls: {lroPollCount}), " + + $"GetEntry: {sw.ElapsedMilliseconds - lroDuration}ms (calls: {getEntryCallCount})"); + + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(getEntryCallCount, Is.EqualTo(3), "Should make 3 GetEntry calls (2 failures + 1 success)"); + + // Should take ~3 seconds total: + // - ~1 second for LRO (SDK default first poll is 1s) + // - ~2 seconds for 2x Retry-After: 1 + Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(2500), + $"Without tuning, should take >2.5s total. Got {sw.ElapsedMilliseconds}ms (LRO: {lroDuration}ms)"); + } + + /// + /// True integration test with BOTH tuning strategies applied: + /// 1. Aggressive LRO polling (100ms fixed interval) + /// 2. Fast TransactionNotCached retries (100ms via MstTransactionNotCachedPolicy) + /// + /// Expected: ~600-800ms total (down from ~3 seconds) + /// + [Test] + public async Task TrueIntegration_FullyTuned_BothPolicies() + { + // Arrange + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + + return new MockResponse(200); + }); + + // Configure with BOTH tuning strategies + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + // Add fast retry policy (100ms instead of 1s Retry-After) + options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(100), + maxRetries: 8); + + var pipeline = HttpPipelineBuilder.Build(options); + + // Mock the Operation with fast polling (100ms instead of SDK default) + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + + // Phase 1: Wait for LRO with fast polling (100ms interval) + await mockOperation.Object.WaitForCompletionAsync( + TimeSpan.FromMilliseconds(100), + CancellationToken.None); + var lroDuration = sw.ElapsedMilliseconds; + + // Phase 2: GetEntryStatement through HTTP pipeline with fast retry policy + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test-entry-123"); + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Console.WriteLine($"[True Integration Tuned] Total: {sw.ElapsedMilliseconds}ms, " + + $"LRO: {lroDuration}ms (polls: {lroPollCount}), " + + $"GetEntry: {sw.ElapsedMilliseconds - lroDuration}ms (calls: {getEntryCallCount})"); + + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(getEntryCallCount, Is.EqualTo(3)); + + // Should take ~600-800ms total: + // - ~400-500ms for LRO (100ms polling, ready at 400ms) + // - ~200ms for 2x 100ms fast retries + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1000), + $"With full tuning, should complete in <1s total. Got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// True integration with 50ms aggressive tuning. + /// + [Test] + public async Task TrueIntegration_AggressiveTuning_50ms() + { + // Arrange + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(50), + maxRetries: 8); + + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + + // Fast LRO polling (50ms) + await mockOperation.Object.WaitForCompletionAsync( + TimeSpan.FromMilliseconds(50), + CancellationToken.None); + var lroDuration = sw.ElapsedMilliseconds; + + // GetEntryStatement with fast retry + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test-entry-123"); + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Assert + Console.WriteLine($"[True Integration 50ms] Total: {sw.ElapsedMilliseconds}ms, " + + $"LRO: {lroDuration}ms (polls: {lroPollCount}), " + + $"GetEntry: {sw.ElapsedMilliseconds - lroDuration}ms (calls: {getEntryCallCount})"); + + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(getEntryCallCount, Is.EqualTo(3)); + + // Should take ~500-600ms total: + // - ~400-450ms for LRO (50ms polling, ready at 400ms) + // - ~100ms for 2x 50ms fast retries + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(800), + $"With 50ms tuning, should complete in <800ms total. Got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// Comparison test showing improvement ratio. + /// + [Test] + public async Task TrueIntegration_ComparisonSummary() + { + var results = new List<(string Config, long TotalMs, long LroMs, long GetEntryMs)>(); + + // Test 1: Baseline (SDK defaults) - LRO ready at 400ms + { + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + return CreateTransactionNotCachedResponse(); + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + await mockOperation.Object.WaitForCompletionAsync(CancellationToken.None); + var lroMs = sw.ElapsedMilliseconds; + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Baseline (no tuning)", sw.ElapsedMilliseconds, lroMs, sw.ElapsedMilliseconds - lroMs)); + } + + // Test 2: Pipeline Policy ONLY (no LRO tuning) - simulates user's current fix + { + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + return CreateTransactionNotCachedResponse(); + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + // Pipeline policy WITH fast retry (user's current fix) + options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + // SDK default LRO polling (NO fast polling - as user has now) + await mockOperation.Object.WaitForCompletionAsync(CancellationToken.None); + var lroMs = sw.ElapsedMilliseconds; + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Policy only (no LRO)", sw.ElapsedMilliseconds, lroMs, sw.ElapsedMilliseconds - lroMs)); + } + + // Test 3: Both tuned (100ms/100ms) + { + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + return CreateTransactionNotCachedResponse(); + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + await mockOperation.Object.WaitForCompletionAsync(TimeSpan.FromMilliseconds(100), CancellationToken.None); + var lroMs = sw.ElapsedMilliseconds; + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Both tuned (100ms)", sw.ElapsedMilliseconds, lroMs, sw.ElapsedMilliseconds - lroMs)); + } + + // Test 3: Aggressive (50ms/50ms) + { + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(400); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + return CreateTransactionNotCachedResponse(); + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(50), 8); + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + await mockOperation.Object.WaitForCompletionAsync(TimeSpan.FromMilliseconds(50), CancellationToken.None); + var lroMs = sw.ElapsedMilliseconds; + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Aggressive (50ms/50ms)", sw.ElapsedMilliseconds, lroMs, sw.ElapsedMilliseconds - lroMs)); + } + + // Output comparison + Console.WriteLine("\n=== TIMING COMPARISON ==="); + Console.WriteLine($"{"Configuration",-25} {"Total",-10} {"LRO",-10} {"GetEntry",-10} {"Speedup",-10}"); + Console.WriteLine(new string('-', 65)); + + long baseline = results[0].TotalMs; + foreach (var r in results) + { + double speedup = (double)baseline / r.TotalMs; + Console.WriteLine($"{r.Config,-25} {r.TotalMs + "ms",-10} {r.LroMs + "ms",-10} {r.GetEntryMs + "ms",-10} {speedup:F1}x"); + } + + // Assert baseline is significantly slower + Assert.That(results[0].TotalMs, Is.GreaterThanOrEqualTo(2500), "Baseline should be >2.5s"); + // Policy only should be ~1.2s (SDK LRO ~1s + fast GetEntry ~200ms) + Assert.That(results[1].TotalMs, Is.LessThan(1800), "Policy only should be <1.8s"); + Assert.That(results[1].TotalMs, Is.GreaterThanOrEqualTo(1000), "Policy only should be >1s (LRO delay)"); + Assert.That(results[2].TotalMs, Is.LessThan(1000), "Both tuned should be <1s"); + Assert.That(results[3].TotalMs, Is.LessThan(800), "Aggressive should be <800ms"); + + // Assert improvement ratio for policy only vs baseline + double policyOnlyImprovement = (double)results[0].TotalMs / results[1].TotalMs; + Console.WriteLine($"\nPolicy-only improvement: {policyOnlyImprovement:F1}x (saves ~{results[0].TotalMs - results[1].TotalMs}ms)"); + + // Assert improvement ratio for full tuning + double improvementRatio = (double)results[0].TotalMs / results[3].TotalMs; + Assert.That(improvementRatio, Is.GreaterThan(3.0), + $"Aggressive tuning should provide >3x improvement. Got {improvementRatio:F1}x"); + } + + /// + /// Tests what happens when the LRO operation takes longer (e.g., 1.5 seconds) + /// requiring multiple SDK polling cycles. This simulates a slower MST CreateEntry response. + /// + /// SDK default exponential backoff: 1s, 2s, 4s, 8s... + /// - If LRO takes 1.5s: poll at 1s (not ready), poll at 3s (ready) = 3s total for LRO + /// - With 100ms tuning: poll at 100ms, 200ms, ... 1500ms (ready) = ~1.5s for LRO + /// + [Test] + public async Task TrueIntegration_LongerLRO_RequiresSecondPoll() + { + var results = new List<(string Config, long TotalMs, long LroMs, long GetEntryMs, int Polls)>(); + + int lroCompletionTimeMs = 1500; // Operation takes 1.5 seconds to complete + + // Test 1: SDK default (no tuning at all) + { + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(lroCompletionTimeMs); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + return CreateTransactionNotCachedResponse(); + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + await mockOperation.Object.WaitForCompletionAsync(CancellationToken.None); + var lroMs = sw.ElapsedMilliseconds; + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Baseline (no tuning)", sw.ElapsedMilliseconds, lroMs, sw.ElapsedMilliseconds - lroMs, lroPollCount)); + } + + // Test 2: Pipeline Policy ONLY (your current fix, no LRO tuning) + { + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(lroCompletionTimeMs); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + return CreateTransactionNotCachedResponse(); + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + // SDK default LRO polling (NO fast polling) + await mockOperation.Object.WaitForCompletionAsync(CancellationToken.None); + var lroMs = sw.ElapsedMilliseconds; + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Policy only (no LRO)", sw.ElapsedMilliseconds, lroMs, sw.ElapsedMilliseconds - lroMs, lroPollCount)); + } + + // Test 3: Both tuned (100ms polling) + { + int lroPollCount = 0; + int getEntryCallCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(lroCompletionTimeMs); + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + getEntryCallCount++; + if (getEntryCallCount <= 2) + return CreateTransactionNotCachedResponse(); + var response = new MockResponse(200); + response.SetContent(CreateMessageWithReceipt().Encode()); + return response; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } + }; + options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + var pipeline = HttpPipelineBuilder.Build(options); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + // Fast LRO polling (100ms) + await mockOperation.Object.WaitForCompletionAsync(TimeSpan.FromMilliseconds(100), CancellationToken.None); + var lroMs = sw.ElapsedMilliseconds; + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Both tuned (100ms)", sw.ElapsedMilliseconds, lroMs, sw.ElapsedMilliseconds - lroMs, lroPollCount)); + } + + // Output comparison + Console.WriteLine($"\n=== LRO COMPLETION TIME: {lroCompletionTimeMs}ms (requires 2nd SDK poll) ==="); + Console.WriteLine($"{"Configuration",-25} {"Total",-10} {"LRO",-12} {"GetEntry",-10} {"Polls",-8} {"Speedup",-10}"); + Console.WriteLine(new string('-', 75)); + + long baseline = results[0].TotalMs; + foreach (var r in results) + { + double speedup = (double)baseline / r.TotalMs; + Console.WriteLine($"{r.Config,-25} {r.TotalMs + "ms",-10} {r.LroMs + "ms",-12} {r.GetEntryMs + "ms",-10} {r.Polls,-8} {speedup:F1}x"); + } + + // Analysis + Console.WriteLine($"\n--- Analysis ---"); + Console.WriteLine($"Policy-only LRO time: {results[1].LroMs}ms (SDK exponential backoff: 1s poll, then 2s poll = ~3s)"); + Console.WriteLine($"Tuned LRO time: {results[2].LroMs}ms (100ms polling, ~15 polls)"); + Console.WriteLine($"Policy-only saves: {results[0].TotalMs - results[1].TotalMs}ms from GetEntry retries"); + Console.WriteLine($"Full tuning saves: {results[0].TotalMs - results[2].TotalMs}ms total"); + + // Key insight: With 1.5s LRO, SDK default will wait 1s (not ready), then 2s more = 3s for LRO alone + Assert.That(results[0].LroMs, Is.GreaterThanOrEqualTo(2800), + $"SDK default should take ~3s for 1.5s LRO (1s + 2s polls). Got {results[0].LroMs}ms"); + Assert.That(results[1].LroMs, Is.GreaterThanOrEqualTo(2800), + $"Policy-only still has SDK LRO timing. Got {results[1].LroMs}ms"); + Assert.That(results[2].LroMs, Is.LessThan(1800), + $"Tuned LRO should complete in ~1.5s. Got {results[2].LroMs}ms"); + } + + /// + /// Parameterized test exploring different LRO completion times. + /// Shows how SDK exponential backoff vs fixed polling affects timing. + /// + [Test] + [TestCase(400, Description = "Fast LRO (400ms) - completes before first SDK poll")] + [TestCase(1200, Description = "Medium LRO (1.2s) - requires 2nd SDK poll at 3s")] + [TestCase(2500, Description = "Slow LRO (2.5s) - requires 2nd SDK poll at 3s")] + [TestCase(3500, Description = "Very slow LRO (3.5s) - requires 3rd SDK poll at 7s")] + public async Task TrueIntegration_VaryingLroTimes(int lroCompletionTimeMs) + { + var results = new List<(string Config, long TotalMs, long LroMs, int Polls)>(); + + // Test 1: SDK default (no LRO tuning) + { + int lroPollCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(lroCompletionTimeMs); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + await mockOperation.Object.WaitForCompletionAsync(CancellationToken.None); + sw.Stop(); + + results.Add(("SDK default", sw.ElapsedMilliseconds, sw.ElapsedMilliseconds, lroPollCount)); + } + + // Test 2: Tuned (100ms polling) + { + int lroPollCount = 0; + var lroReadyTime = DateTimeOffset.UtcNow.AddMilliseconds(lroCompletionTimeMs); + var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); + + var sw = Stopwatch.StartNew(); + await mockOperation.Object.WaitForCompletionAsync(TimeSpan.FromMilliseconds(100), CancellationToken.None); + sw.Stop(); + + results.Add(("100ms polling", sw.ElapsedMilliseconds, sw.ElapsedMilliseconds, lroPollCount)); + } + + // Output + Console.WriteLine($"\n=== LRO COMPLETION TIME: {lroCompletionTimeMs}ms ==="); + Console.WriteLine($"{"Config",-20} {"Duration",-12} {"Polls",-8} {"Overhead",-12}"); + Console.WriteLine(new string('-', 52)); + + foreach (var r in results) + { + long overhead = r.TotalMs - lroCompletionTimeMs; + Console.WriteLine($"{r.Config,-20} {r.TotalMs + "ms",-12} {r.Polls,-8} {(overhead > 0 ? "+" : "")}{overhead}ms"); + } + + long sdkOverhead = results[0].TotalMs - lroCompletionTimeMs; + long tunedOverhead = results[1].TotalMs - lroCompletionTimeMs; + Console.WriteLine($"\nSDK overhead: {sdkOverhead}ms, Tuned overhead: {tunedOverhead}ms"); + Console.WriteLine($"Savings from LRO tuning: {results[0].TotalMs - results[1].TotalMs}ms"); + + // Tuned should always be close to actual LRO time + Assert.That(results[1].TotalMs, Is.LessThan(lroCompletionTimeMs + 200), + $"Tuned LRO should complete within 200ms of actual time. Got {results[1].TotalMs}ms for {lroCompletionTimeMs}ms LRO"); + } + + #endregion +} From fd68ef316aa1448bb131c9c2ce148a47e52fa94e Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Fri, 20 Mar 2026 22:57:29 -0700 Subject: [PATCH 03/16] fix: Relax timing tolerances in MstEndToEndTimingTests for CI stability Widen timing thresholds that were too tight for CI runners (macOS ARM64): - TrueIntegration_AggressiveTuning_50ms: 800ms -> 1000ms (got 809ms) - TrueIntegration_ComparisonSummary aggressive: 800ms -> 1000ms - TrueIntegration_VaryingLroTimes overhead: 200ms -> 350ms (got 249/243ms) The meaningful assertions (improvement ratios, relative ordering) remain unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstEndToEndTimingTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs index cf8bc829..6ad2fce6 100644 --- a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -955,8 +955,8 @@ await mockOperation.Object.WaitForCompletionAsync( // Should take ~500-600ms total: // - ~400-450ms for LRO (50ms polling, ready at 400ms) // - ~100ms for 2x 50ms fast retries - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(800), - $"With 50ms tuning, should complete in <800ms total. Got {sw.ElapsedMilliseconds}ms"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1000), + $"With 50ms tuning, should complete in <1s total. Got {sw.ElapsedMilliseconds}ms"); } /// @@ -1146,7 +1146,7 @@ public async Task TrueIntegration_ComparisonSummary() Assert.That(results[1].TotalMs, Is.LessThan(1800), "Policy only should be <1.8s"); Assert.That(results[1].TotalMs, Is.GreaterThanOrEqualTo(1000), "Policy only should be >1s (LRO delay)"); Assert.That(results[2].TotalMs, Is.LessThan(1000), "Both tuned should be <1s"); - Assert.That(results[3].TotalMs, Is.LessThan(800), "Aggressive should be <800ms"); + Assert.That(results[3].TotalMs, Is.LessThan(1000), "Aggressive should be <1s"); // Assert improvement ratio for policy only vs baseline double policyOnlyImprovement = (double)results[0].TotalMs / results[1].TotalMs; @@ -1378,8 +1378,8 @@ public async Task TrueIntegration_VaryingLroTimes(int lroCompletionTimeMs) Console.WriteLine($"Savings from LRO tuning: {results[0].TotalMs - results[1].TotalMs}ms"); // Tuned should always be close to actual LRO time - Assert.That(results[1].TotalMs, Is.LessThan(lroCompletionTimeMs + 200), - $"Tuned LRO should complete within 200ms of actual time. Got {results[1].TotalMs}ms for {lroCompletionTimeMs}ms LRO"); + Assert.That(results[1].TotalMs, Is.LessThan(lroCompletionTimeMs + 350), + $"Tuned LRO should complete within 350ms of actual time. Got {results[1].TotalMs}ms for {lroCompletionTimeMs}ms LRO"); } #endregion From 3a8026780e4797017734d4df49a4bab857ddb318 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Mon, 23 Mar 2026 11:01:14 -0700 Subject: [PATCH 04/16] Add comprehensive architecture and developer training guide --- docs/ARCHITECTURE.md | 829 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 829 insertions(+) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..ce280274 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,829 @@ +# CoseSignTool Architecture & Developer Training Guide + +This guide provides a comprehensive overview of the CoseSignTool repository for developers who want to understand, maintain, or extend the codebase. + +## Table of Contents + +1. [Overview](#overview) +2. [Project Structure](#project-structure) +3. [Architecture Layers](#architecture-layers) +4. [Core Abstractions](#core-abstractions) +5. [Design Patterns](#design-patterns) +6. [Data Flow](#data-flow) +7. [Key Components Deep Dive](#key-components-deep-dive) +8. [Plugin System](#plugin-system) +9. [SCITT Compliance](#scitt-compliance) +10. [Transparency Services](#transparency-services) +11. [Testing Strategy](#testing-strategy) +12. [Development Workflows](#development-workflows) +13. [Common Tasks](#common-tasks) +14. [Troubleshooting Guide](#troubleshooting-guide) + +--- + +## Overview + +CoseSignTool is a Microsoft project for creating and validating **COSE (CBOR Object Signing and Encryption)** signatures, primarily used for: + +- Signing Software Bills of Materials (SBOMs) +- Supply chain integrity (SCITT compliance) +- IoT device authentication +- General-purpose cryptographic signing + +The repository provides: +- **CLI tool** (`CoseSignTool`) for command-line operations +- **.NET libraries** for programmatic access +- **Plugin system** for extensibility +- **Transparency service integration** (Microsoft Signing Transparency) + +### Key Technologies + +| Technology | Purpose | +|------------|---------| +| `System.Security.Cryptography.Cose` | Native COSE implementation (.NET 7+) | +| `System.Formats.Cbor` | CBOR encoding/decoding | +| Azure SDK (`Azure.Core`, `Azure.Identity`) | Cloud service integration | +| NUnit/Moq | Testing framework | + +--- + +## Project Structure + +``` +CoseSignTool3/ +├── 📦 Core Libraries (NuGet packages) +│ ├── CoseSign1.Abstractions/ # Interfaces & base types +│ ├── CoseSign1/ # Factory implementations +│ ├── CoseSign1.Headers/ # CWT Claims & header extensions +│ ├── CoseSign1.Certificates/ # X.509 certificate integration +│ └── CoseHandler/ # High-level static API +│ +├── 📦 Extended Libraries +│ ├── CoseIndirectSignature/ # Large payload support +│ ├── CoseSign1.Transparent/ # Transparency service base +│ └── CoseSign1.Transparent.MST/ # Microsoft Signing Transparency +│ +├── 🔧 CLI Application +│ ├── CoseSignTool/ # Main executable +│ └── CoseSignTool.Abstractions/ # Plugin interfaces +│ +├── 🔌 Plugins +│ ├── CoseSignTool.IndirectSignature.Plugin/ +│ ├── CoseSignTool.MST.Plugin/ +│ ├── CoseSignTool.AzureArtifactSigning.Plugin/ +│ └── CoseSign1.Certificates.AzureArtifactSigning/ +│ +├── 🧪 Test Projects (17+ projects) +│ ├── CoseSign1.Tests/ +│ ├── CoseHandler.Tests/ +│ ├── CoseSign1.Transparent.MST.Tests/ +│ └── ... (one test project per library) +│ +├── 📚 Documentation +│ └── docs/ # 27 markdown files +│ +└── 🔧 Build Configuration + ├── Directory.Build.props # Shared MSBuild settings + ├── Directory.Packages.props # Central package versions + └── CoseSignTool.sln # Solution file +``` + +### Target Frameworks + +| Project Type | Targets | +|--------------|---------| +| Libraries | `netstandard2.0` + `net8.0` | +| CLI & Plugins | `net8.0` only | +| Tests | `net8.0` only | + +--- + +## Architecture Layers + +``` +┌───────────────────────────────────────────────────────────────┐ +│ CLI / Applications │ +│ ┌─────────────┐ ┌───────────────┐ ┌─────────────────────┐ │ +│ │ CoseSignTool│ │ Custom Apps │ │ Plugins │ │ +│ │ (CLI) │ │ (Your Code) │ │ (MST, Indirect, etc)│ │ +│ └──────┬──────┘ └───────┬───────┘ └──────────┬──────────┘ │ +└─────────┼─────────────────┼─────────────────────┼─────────────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────────────────────────────────────────────────────┐ +│ High-Level API Layer │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ CoseHandler │ │ +│ │ Sign() | Validate() | GetPayload() │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ Core Implementation Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ CoseSign1 │ │ CoseSign1 │ │ CoseIndirect │ │ +│ │ (Factory/ │ │ .Certificates│ │ Signature │ │ +│ │ Builder) │ │ (X.509 Keys) │ │ (Hash Envelopes) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────────┬──────────┘ │ +│ │ │ │ │ +│ └─────────────────┼─────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┴────────────────────────────────┐ │ +│ │ CoseSign1.Headers │ │ +│ │ (CWT Claims, Header Extenders) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ Abstraction Layer │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ CoseSign1.Abstractions │ │ +│ │ ICoseSigningKeyProvider | ICoseHeaderExtender | Validators │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ .NET BCL / Azure SDK │ +│ System.Security.Cryptography.Cose | Azure.Core | Azure.Identity │ +└───────────────────────────────────────────────────────────────┘ +``` + +--- + +## Core Abstractions + +### ICoseSigningKeyProvider + +The **primary abstraction** for all signing operations. Any key source (certificate, HSM, cloud) must implement this interface. + +```csharp +public interface ICoseSigningKeyProvider +{ + // Key material + RSA? GetRSAKey(bool publicKey = false); + ECDsa? GetECDsaKey(bool publicKey = false); + + // Algorithm selection + HashAlgorithmName HashAlgorithm { get; } + + // Certificate chain (for X.509 scenarios) + IEnumerable? GetCertificateChain(); + + // SCITT compliance + string? Issuer { get; } + + // Validation + bool IsRSA { get; } + IEnumerable? GetProtectedHeaders(); + IEnumerable? GetUnprotectedHeaders(); +} +``` + +**Implementations:** +- `X509Certificate2CoseSigningKeyProvider` - Local certificate files +- `AzureArtifactSigningCoseSigningKeyProvider` - Azure cloud signing + +### ICoseHeaderExtender + +Adds custom headers to COSE messages before signing. + +```csharp +public interface ICoseHeaderExtender +{ + void ExtendProtectedHeaders(CoseHeaderMap protectedHeaders); + void ExtendUnprotectedHeaders(CoseHeaderMap unprotectedHeaders); +} +``` + +**Key Implementation:** `CWTClaimsHeaderExtender` - Adds SCITT-compliant CWT claims + +### CoseSign1MessageValidator + +Abstract base for validation chains (Chain of Responsibility pattern). + +```csharp +public abstract class CoseSign1MessageValidator +{ + public CoseSign1MessageValidator? NextValidator { get; set; } + + public abstract CoseSign1ValidationResult Validate( + CoseSign1Message message, + ReadOnlyMemory? payload); +} +``` + +--- + +## Design Patterns + +### 1. Factory Pattern + +**Where:** `CoseSign1MessageFactory`, `IndirectSignatureFactory` + +```csharp +// Create messages with consistent configuration +var factory = new CoseSign1MessageFactory(); +CoseSign1Message msg = factory.CreateCoseSign1Message(payload, keyProvider); +``` + +### 2. Builder Pattern + +**Where:** `CoseSign1MessageBuilder` + +```csharp +// Fluent API for complex configuration +var message = new CoseSign1MessageBuilder() + .SetPayloadBytes(payload) + .SetContentType("application/spdx+json") + .ExtendCoseHeader(new CWTClaimsHeaderExtender().SetSubject("release.v1")) + .Build(keyProvider); +``` + +### 3. Chain of Responsibility + +**Where:** `CoseSign1MessageValidator` and derived validators + +```csharp +// Compose validators as a linked list +var validator = new SignatureValidator +{ + NextValidator = new CertificateChainValidator + { + NextValidator = new ExpirationValidator() + } +}; +var result = validator.Validate(message, payload); +``` + +### 4. Strategy Pattern + +**Where:** `ICoseSigningKeyProvider`, `ICoseHeaderExtender` + +```csharp +// Swap signing strategies at runtime +ICoseSigningKeyProvider keyProvider = useCloud + ? new AzureArtifactSigningProvider(...) + : new X509Certificate2CoseSigningKeyProvider(cert); +``` + +### 5. Decorator Pattern + +**Where:** Header extenders + +```csharp +// Layer header modifications +var extender = new X509CertificateWithCWTClaimsHeaderExtender( + certProvider, + new CWTClaimsHeaderExtender() + .SetSubject("my-app") + .SetAudience("prod-cluster")); +``` + +### 6. Plugin Architecture + +**Where:** `CoseSignTool.Abstractions` + +```csharp +// Two extension points +public interface ICoseSignToolPlugin { ... } // Add commands +public interface ICertificateProviderPlugin { ... } // Add key sources +``` + +--- + +## Data Flow + +### Signing Flow + +``` +┌─────────┐ ┌──────────────┐ ┌────────────────┐ ┌──────────┐ +│ Payload │───▶│ KeyProvider │───▶│ HeaderExtender │───▶│ Factory │ +│ (bytes) │ │ (keys+chain) │ │ (CWT claims) │ │ (create) │ +└─────────┘ └──────────────┘ └────────────────┘ └────┬─────┘ + │ + ▼ + ┌──────────────┐ + │ CoseSign1 │ + │ Message │ + │ (signature) │ + └──────────────┘ +``` + +### Validation Flow + +``` +┌──────────────┐ ┌────────────────┐ ┌────────────────┐ +│ CoseSign1 │───▶│ Validator │───▶│ Next Validator │───▶ ... +│ Message │ │ Chain Head │ │ (chain link) │ +└──────────────┘ └────────────────┘ └────────────────┘ + │ + ▼ + ┌────────────────────┐ + │ ValidationResult │ + │ (pass/fail+details)│ + └────────────────────┘ +``` + +--- + +## Key Components Deep Dive + +### CoseHandler (High-Level API) + +The simplest entry point for most scenarios: + +```csharp +// Sign a file +byte[] signature = CoseHandler.Sign( + payloadBytes, + certificate, + embedPayload: false); + +// Validate a signature +ValidationResult result = CoseHandler.Validate( + signatureBytes, + payloadBytes); + +// Extract embedded payload +byte[] payload = CoseHandler.GetPayload(signatureBytes); +``` + +**30+ overloads** support different input combinations (files, streams, certificates, thumbprints). + +### CoseSign1.Certificates + +Bridges X.509 certificates to COSE signing: + +```csharp +// From PFX file +var provider = new X509Certificate2CoseSigningKeyProvider( + new X509Certificate2("cert.pfx", "password")); + +// With custom chain builder +var provider = new X509Certificate2CoseSigningKeyProvider( + new X509ChainBuilder(customRoots), + certificate); + +// Auto-generates: +// - x5t header (thumbprint) +// - x5chain header (certificate chain) +// - DID:x509 issuer (for SCITT) +``` + +### CoseSign1.Headers (SCITT Compliance) + +```csharp +// Automatic CWT claims via certificate provider +var provider = new X509Certificate2CoseSigningKeyProvider(cert) +{ + EnableScittCompliance = true // Default: true +}; + +// Manual CWT claims +var extender = new CWTClaimsHeaderExtender() + .SetIssuer("did:x509:0:sha256:abc::subject:CN=My%20CA") + .SetSubject("software.release.v1.0") + .SetAudience("production") + .SetExpirationTime(DateTime.UtcNow.AddYears(1)) + .SetCustomClaim(100, "custom-value"); +``` + +### CoseIndirectSignature + +For payloads too large to embed: + +```csharp +// Sign hash instead of full payload +var factory = new IndirectSignatureFactory(); +CoseSign1Message signature = factory.CreateIndirectSignature( + largePayloadStream, + keyProvider); + +// Content-Type becomes: application/original-type+cose-hash-v +``` + +--- + +## Plugin System + +### Architecture + +``` +┌───────────────────────────────────────────────────────────────┐ +│ CoseSignTool │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ PluginLoader │ │ +│ │ Auto-discovers plugins from ./plugins/{Name}/ folders │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────────┐ ┌───────────────────────────┐ │ +│ │ ICoseSignToolPlugin │ │ ICertificateProviderPlugin│ │ +│ │ (add commands) │ │ (add key sources) │ │ +│ └────────────────────────┘ └───────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ +``` + +### Existing Plugins + +| Plugin | Purpose | Commands Added | +|--------|---------|----------------| +| MST Plugin | Microsoft Signing Transparency | `mst:register`, `mst:verify` | +| IndirectSignature Plugin | Large file support | `indirect-sign` | +| AzureArtifactSigning Plugin | Cloud signing | Certificate provider | + +### Creating a Plugin + +See [PluginQuickStart.md](PluginQuickStart.md) for the complete guide. + +```csharp +// Implement the interface +public class MyPlugin : ICoseSignToolPlugin +{ + public string Name => "my-plugin"; + public string Version => "1.0.0"; + public IEnumerable Commands { get; } + + public void Initialize(IPluginLogger logger) { ... } +} +``` + +--- + +## SCITT Compliance + +### What is SCITT? + +**Supply Chain Integrity, Transparency, and Trust** - An IETF standard for verifiable supply chain signatures. + +### Key Concepts + +| Concept | Implementation | +|---------|----------------| +| **DID:x509** | Auto-generated issuer from certificate chain | +| **CWT Claims** | CBOR Web Token standard claims in COSE headers | +| **Transparency Log** | Optional registration with MST service | + +### DID:x509 Format + +``` +did:x509:0:sha256:::subject:CN= +``` + +Example: +``` +did:x509:0:sha256:WE50Zg...::subject:CN=Microsoft%20Code%20Signing%20CA +``` + +### Standard CWT Claims + +| Claim | Key | Description | +|-------|-----|-------------| +| `iss` | 1 | Issuer (auto: DID:x509) | +| `sub` | 2 | Subject (default: "unknown.intent") | +| `aud` | 3 | Audience (optional) | +| `exp` | 4 | Expiration Time (optional) | +| `nbf` | 5 | Not Before (auto: signing time) | +| `iat` | 6 | Issued At (auto: signing time) | + +--- + +## Transparency Services + +### CoseSign1.Transparent Architecture + +``` +┌───────────────────────────────────────────────────────────────┐ +│ TransparencyService (abstract) │ +│ MakeTransparentAsync() | VerifyTransparencyAsync() │ +└─────────────────────────────┬─────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌───────────────────────┐ ┌───────────────────────────┐ +│ MstTransparencyService│ │ YourCustomService │ +│ (Azure Code │ │ (implement abstract │ +│ Transparency) │ │ methods) │ +└───────────────────────┘ └───────────────────────────┘ +``` + +### MST Flow + +``` +┌─────────────┐ +│ Sign locally│ +│ (COSE msg) │ +└──────┬──────┘ + │ + ▼ +┌─────────────────────────┐ +│ MstTransparencyService │ +│ .MakeTransparentAsync() │ +└──────────┬──────────────┘ + │ + ▼ +┌─────────────────────────┐ ┌────────────────────┐ +│ CreateEntryAsync (LRO) │───▶│ Azure Code │ +│ (submit to ledger) │ │ Transparency │ +└─────────────────────────┘ │ Service │ + │ └────────────────────┘ + ▼ +┌─────────────────────────┐ +│ GetEntryStatementAsync │ +│ (retrieve receipt) │ +└──────────┬──────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ CoseSign1Message with │ +│ transparency receipt │ +└─────────────────────────┘ +``` + +### Performance Tuning + +The MST service has specific timing characteristics: + +| Component | Default Timing | Tuned Timing | +|-----------|----------------|--------------| +| LRO Polling | ~1s (SDK exponential) | ~100ms (fixed) | +| 503 Retry (TransactionNotCached) | ~1s (Retry-After) | ~100ms (fast retry) | +| **Total** | **~3 seconds** | **~600ms** | + +```csharp +// Apply both tuning strategies +var options = new CodeTransparencyClientOptions(); +options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(100), + maxRetries: 8); + +var pollingOptions = new MstPollingOptions +{ + PollingInterval = TimeSpan.FromMilliseconds(100) +}; + +var service = new MstTransparencyService(client, pollingOptions); +``` + +--- + +## Testing Strategy + +### Test Organization + +``` +Tests/ +├── Unit Tests (fast, isolated) +│ ├── CoseSign1.Tests +│ ├── CoseSign1.Headers.Tests +│ └── CoseSign1.Certificates.Tests +│ +├── Integration Tests (slower, external deps) +│ ├── CoseHandler.Tests +│ ├── CoseSignTool.Tests +│ └── CoseSign1.Transparent.MST.Tests +│ +└── Test Utilities + ├── CoseSign1.Tests.Common # Shared helpers + └── Azure.Core.TestCommon # Mock HTTP transport +``` + +### Test Patterns + +**Unit Test Example:** +```csharp +[Test] +public void Factory_CreateMessage_WithValidPayload_Succeeds() +{ + // Arrange + var factory = new CoseSign1MessageFactory(); + var keyProvider = CreateTestKeyProvider(); + var payload = Encoding.UTF8.GetBytes("test"); + + // Act + var message = factory.CreateCoseSign1Message(payload, keyProvider); + + // Assert + Assert.That(message, Is.Not.Null); + Assert.That(message.Content.HasValue, Is.True); +} +``` + +**Mock HTTP Transport (for Azure SDK):** +```csharp +var transport = MockTransport.FromMessageCallback(msg => +{ + if (msg.Request.Uri.ToUri().AbsoluteUri.Contains("/entries/")) + { + return new MockResponse(503) + .AddHeader("Retry-After", "1") + .SetContent(CreateCborErrorBody("TransactionNotCached")); + } + return new MockResponse(200); +}); +``` + +### Running Tests + +```bash +# All tests +dotnet test + +# Specific project +dotnet test CoseSign1.Tests/CoseSign1.Tests.csproj + +# With filter +dotnet test --filter "ClassName~MstTransparencyService" + +# With coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +--- + +## Development Workflows + +### Building the Solution + +```bash +# Full build +dotnet build CoseSignTool.sln + +# Release build +dotnet build CoseSignTool.sln -c Release + +# Specific project +dotnet build CoseSign1/CoseSign1.csproj +``` + +### Publishing CLI + +```bash +# Self-contained executable (no .NET required) +dotnet publish CoseSignTool/CoseSignTool.csproj \ + -c Release \ + -r win-x64 \ + --self-contained true \ + -p:PublishSingleFile=true +``` + +### Adding a New Library + +1. Create project in appropriate folder +2. Add to `CoseSignTool.sln` +3. Reference `CoseSign1.Abstractions` for core interfaces +4. Create corresponding test project +5. Add documentation in `docs/` + +### Making Breaking Changes + +1. Update version in `.csproj` +2. Document in `CHANGELOG.md` +3. Update affected documentation +4. Ensure backward compatibility tests pass (if applicable) + +--- + +## Common Tasks + +### Adding a Custom Header Extender + +```csharp +public class MyHeaderExtender : ICoseHeaderExtender +{ + public void ExtendProtectedHeaders(CoseHeaderMap headers) + { + // Add your protected claims + headers.Add(new CoseHeaderLabel(100), "my-value"); + } + + public void ExtendUnprotectedHeaders(CoseHeaderMap headers) + { + // Unprotected headers (visible without verification) + } +} + +// Usage +var message = new CoseSign1MessageBuilder() + .SetPayloadBytes(payload) + .ExtendCoseHeader(new MyHeaderExtender()) + .Build(keyProvider); +``` + +### Creating a Custom Validator + +```csharp +public class MyValidator : CoseSign1MessageValidator +{ + public override CoseSign1ValidationResult Validate( + CoseSign1Message message, + ReadOnlyMemory? payload) + { + // Your validation logic + if (!IsValid(message)) + { + return new CoseSign1ValidationResult( + ValidationResultCode.Failed, + "My validation failed"); + } + + // Pass to next validator + return NextValidator?.Validate(message, payload) + ?? CoseSign1ValidationResult.Success; + } +} +``` + +### Using Stream-Based Signing (for large files) + +```csharp +await using var payloadStream = File.OpenRead("large-file.bin"); + +var message = await factory.CreateCoseSign1MessageAsync( + payloadStream, + keyProvider, + contentType: "application/octet-stream"); +``` + +--- + +## Troubleshooting Guide + +### Common Issues + +| Issue | Cause | Solution | +|-------|-------|----------| +| "Certificate chain validation failed" | Missing intermediate/root certs | Use `--Roots` option or install certs | +| "Private key not found" | Certificate without private key | Export with private key or use HSM | +| SCITT claims missing | `EnableScittCompliance = false` | Set to `true` or add extender manually | +| MST registration slow (~3s) | SDK default polling | Apply `MstPollingOptions` tuning | +| Plugin not loading | Wrong folder structure | Use `plugins/{Name}/{Name}.dll` | + +### Debugging Tips + +1. **Enable verbose logging:** `--Verbose` in CLI +2. **Check certificate chain:** `--ShowCertificateDetails` +3. **Inspect COSE message:** Use `cbor.me` online decoder +4. **Test key provider:** Call `GetRSAKey()`/`GetECDsaKey()` directly + +### Getting Help + +- [Troubleshooting.md](Troubleshooting.md) - Common issues +- [GitHub Issues](https://github.com/microsoft/CoseSignTool/issues) +- [SUPPORT.md](SUPPORT.md) - Support channels + +--- + +## Quick Reference + +### CLI Commands + +```bash +# Sign +CoseSignTool sign --p payload.txt --pfx cert.pfx --sf signature.cose + +# Validate +CoseSignTool validate --sf signature.cose --p payload.txt + +# Extract payload +CoseSignTool get --sf embedded.cose --sa payload.txt +``` + +### .NET Quick Start + +```csharp +// Minimal signing +var cert = new X509Certificate2("cert.pfx", "password"); +var provider = new X509Certificate2CoseSigningKeyProvider(cert); +var signature = CoseHandler.Sign(payload, cert); + +// Minimal validation +var result = CoseHandler.Validate(signature, payload); +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `Directory.Build.props` | Shared build settings | +| `Directory.Packages.props` | Central package versions | +| `CHANGELOG.md` | Version history | +| `docs/` | All documentation | + +--- + +## Further Reading + +- [CoseSignTool.md](CoseSignTool.md) - Complete CLI reference +- [CoseHandler.md](CoseHandler.md) - High-level API guide +- [Advanced.md](Advanced.md) - Async operations, custom validators +- [SCITTCompliance.md](SCITTCompliance.md) - SCITT & CWT Claims +- [Plugins.md](Plugins.md) - Plugin development guide +- [MST.md](MST.md) - Microsoft Signing Transparency + +--- + +*Last updated: March 2026* From 0f35ea727da80318c5b48e5f5f4080e953972a44 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Mon, 23 Mar 2026 13:07:20 -0700 Subject: [PATCH 05/16] fix: Further relax timing tolerances for macOS ARM64 CI runners Combined LRO+GetEntry tests: 1000ms -> 1200ms to account for CI overhead. The improvement ratio assertion (>3x speedup) remains the meaningful validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstEndToEndTimingTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs index 6ad2fce6..0822285d 100644 --- a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -884,8 +884,9 @@ await mockOperation.Object.WaitForCompletionAsync( // Should take ~600-800ms total: // - ~400-500ms for LRO (100ms polling, ready at 400ms) // - ~200ms for 2x 100ms fast retries - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1000), - $"With full tuning, should complete in <1s total. Got {sw.ElapsedMilliseconds}ms"); + // Allow 1200ms for CI runner overhead + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1200), + $"With full tuning, should complete in <1.2s total. Got {sw.ElapsedMilliseconds}ms"); } /// @@ -955,8 +956,9 @@ await mockOperation.Object.WaitForCompletionAsync( // Should take ~500-600ms total: // - ~400-450ms for LRO (50ms polling, ready at 400ms) // - ~100ms for 2x 50ms fast retries - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1000), - $"With 50ms tuning, should complete in <1s total. Got {sw.ElapsedMilliseconds}ms"); + // Allow 1200ms for CI runner overhead + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1200), + $"With 50ms tuning, should complete in <1.2s total. Got {sw.ElapsedMilliseconds}ms"); } /// @@ -1145,8 +1147,8 @@ public async Task TrueIntegration_ComparisonSummary() // Policy only should be ~1.2s (SDK LRO ~1s + fast GetEntry ~200ms) Assert.That(results[1].TotalMs, Is.LessThan(1800), "Policy only should be <1.8s"); Assert.That(results[1].TotalMs, Is.GreaterThanOrEqualTo(1000), "Policy only should be >1s (LRO delay)"); - Assert.That(results[2].TotalMs, Is.LessThan(1000), "Both tuned should be <1s"); - Assert.That(results[3].TotalMs, Is.LessThan(1000), "Aggressive should be <1s"); + Assert.That(results[2].TotalMs, Is.LessThan(1200), "Both tuned should be <1.2s"); + Assert.That(results[3].TotalMs, Is.LessThan(1200), "Aggressive should be <1.2s"); // Assert improvement ratio for policy only vs baseline double policyOnlyImprovement = (double)results[0].TotalMs / results[1].TotalMs; From 31ab3656f48e6acd63f1acc23bf94a652d38addf Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Mon, 23 Mar 2026 23:34:44 -0700 Subject: [PATCH 06/16] Rename MstTransactionNotCachedPolicy to MstPerformanceOptimizationPolicy - Rename class: MstTransactionNotCachedPolicy -> MstPerformanceOptimizationPolicy - Rename extension: ConfigureTransactionNotCachedRetry -> ConfigureMstPerformanceOptimizations - Rename test file: MstTransactionNotCachedPolicyTests -> MstPerformanceOptimizationPolicyTests - Update XML documentation to reflect broader scope (fast retries + header stripping) - Update user docs (CoseSign1.Transparent.md, ARCHITECTURE.md) - Update plugin reference (CodeTransparencyClientHelper.cs) The policy now handles multiple optimizations beyond just TransactionNotCached: 1. Fast retries for 503 responses on /entries/ endpoints 2. Retry-After header stripping for /entries/ and /operations/ endpoints --- .../MstEndToEndTimingTests.cs | 305 ++++++++++++++- ... MstPerformanceOptimizationPolicyTests.cs} | 138 +++---- .../Extensions/MstClientOptionsExtensions.cs | 26 +- .../MstPerformanceOptimizationPolicy.cs | 353 ++++++++++++++++++ .../MstTransactionNotCachedPolicy.cs | 303 --------------- .../CodeTransparencyClientHelper.cs | 2 +- docs/ARCHITECTURE.md | 2 +- docs/CoseSign1.Transparent.md | 26 +- 8 files changed, 745 insertions(+), 410 deletions(-) rename CoseSign1.Transparent.MST.Tests/{MstTransactionNotCachedPolicyTests.cs => MstPerformanceOptimizationPolicyTests.cs} (79%) create mode 100644 CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs delete mode 100644 CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs index 0822285d..561a661f 100644 --- a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -190,7 +190,7 @@ public async Task Tuned_LroPolling_FixedInterval50ms() /// Simulates the full scenario: /// - LRO completes after 400ms /// - GetEntryStatementAsync returns 503 on first 2 calls, success on 3rd - /// - WITHOUT the MstTransactionNotCachedPolicy + /// - WITHOUT the MstPerformanceOptimizationPolicy /// /// This should show the 3-second behavior due to SDK's Retry-After: 1 delay. /// @@ -243,7 +243,7 @@ public async Task FullScenario_Without_TransactionNotCachedPolicy_Shows3SecondBe } /// - /// Same scenario but WITH the MstTransactionNotCachedPolicy. + /// Same scenario but WITH the MstPerformanceOptimizationPolicy. /// Should resolve much faster due to aggressive fast retries. /// [Test] @@ -272,7 +272,7 @@ public async Task FullScenario_With_TransactionNotCachedPolicy_ResolvesQuickly() Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; // Add the fast retry policy with 100ms interval - options.ConfigureTransactionNotCachedRetry( + options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(100), maxRetries: 8); @@ -323,7 +323,7 @@ public async Task FullScenario_With_TransactionNotCachedPolicy_50msDelay() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; - options.ConfigureTransactionNotCachedRetry( + options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(50), maxRetries: 8); @@ -812,7 +812,7 @@ public async Task TrueIntegration_Baseline_NoTuning_Measures3SecondBehavior() /// /// True integration test with BOTH tuning strategies applied: /// 1. Aggressive LRO polling (100ms fixed interval) - /// 2. Fast TransactionNotCached retries (100ms via MstTransactionNotCachedPolicy) + /// 2. Fast TransactionNotCached retries (100ms via MstPerformanceOptimizationPolicy) /// /// Expected: ~600-800ms total (down from ~3 seconds) /// @@ -850,7 +850,7 @@ public async Task TrueIntegration_FullyTuned_BothPolicies() Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; // Add fast retry policy (100ms instead of 1s Retry-After) - options.ConfigureTransactionNotCachedRetry( + options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(100), maxRetries: 8); @@ -924,7 +924,7 @@ public async Task TrueIntegration_AggressiveTuning_50ms() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; - options.ConfigureTransactionNotCachedRetry( + options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(50), maxRetries: 8); @@ -1035,7 +1035,7 @@ public async Task TrueIntegration_ComparisonSummary() Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; // Pipeline policy WITH fast retry (user's current fix) - options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + options.ConfigureMstPerformanceOptimizations(TimeSpan.FromMilliseconds(100), 8); var pipeline = HttpPipelineBuilder.Build(options); var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); @@ -1076,7 +1076,7 @@ public async Task TrueIntegration_ComparisonSummary() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; - options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + options.ConfigureMstPerformanceOptimizations(TimeSpan.FromMilliseconds(100), 8); var pipeline = HttpPipelineBuilder.Build(options); var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); @@ -1116,7 +1116,7 @@ public async Task TrueIntegration_ComparisonSummary() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; - options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(50), 8); + options.ConfigureMstPerformanceOptimizations(TimeSpan.FromMilliseconds(50), 8); var pipeline = HttpPipelineBuilder.Build(options); var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); @@ -1240,7 +1240,7 @@ public async Task TrueIntegration_LongerLRO_RequiresSecondPoll() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; - options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + options.ConfigureMstPerformanceOptimizations(TimeSpan.FromMilliseconds(100), 8); var pipeline = HttpPipelineBuilder.Build(options); var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); @@ -1281,7 +1281,7 @@ public async Task TrueIntegration_LongerLRO_RequiresSecondPoll() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(10) } }; - options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100), 8); + options.ConfigureMstPerformanceOptimizations(TimeSpan.FromMilliseconds(100), 8); var pipeline = HttpPipelineBuilder.Build(options); var mockOperation = CreateTimedOperation(lroReadyTime, () => lroPollCount++); @@ -1385,4 +1385,285 @@ public async Task TrueIntegration_VaryingLroTimes(int lroCompletionTimeMs) } #endregion + + #region Retry-After Header Stripping Tests + + /// + /// Verifies that the policy strips Retry-After headers from 503 /entries/ responses. + /// Without header stripping, SDK would wait 1 second per retry. + /// + [Test] + public async Task RetryAfterStripping_Entries503_HeaderIsStripped() + { + // Arrange + int callCount = 0; + bool retryAfterWasPresentOnOriginal = false; + bool retryAfterWasPresentAfterPolicy = false; + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + callCount++; + if (callCount <= 2) + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + retryAfterWasPresentOnOriginal = response.Headers.Contains("Retry-After"); + return response; + } + var successResponse = new MockResponse(200); + successResponse.SetContent(CreateMessageWithReceipt().Encode()); + return successResponse; + } + return new MockResponse(200); + }); + + // Configure with our policy (fast retries + header stripping) + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(50), 8); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 0 } // Disable SDK retries so we can observe policy behavior + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + + var sw = Stopwatch.StartNew(); + + // Act + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test-entry-123"); + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + + // Check if Retry-After is present after policy processed the response + retryAfterWasPresentAfterPolicy = message.Response.Headers.Contains("Retry-After"); + + // Assert + Console.WriteLine($"[Entries 503 Header Strip] Duration: {sw.ElapsedMilliseconds}ms, " + + $"Calls: {callCount}, Retry-After present on original: {retryAfterWasPresentOnOriginal}, " + + $"Retry-After present after policy: {retryAfterWasPresentAfterPolicy}"); + + Assert.That(retryAfterWasPresentOnOriginal, Is.True, "Original response should have Retry-After header"); + Assert.That(retryAfterWasPresentAfterPolicy, Is.False, "Policy should strip Retry-After header from final response"); + Assert.That(message.Response.Status, Is.EqualTo(200), "Final response should be success"); + Assert.That(callCount, Is.EqualTo(3), "Should make 3 calls (2 failures + 1 success)"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), "With 50ms retries, should complete in <500ms"); + } + + /// + /// Verifies that the policy strips Retry-After headers from /operations/ responses. + /// This enables our configured LRO polling interval to be used. + /// + [Test] + public async Task RetryAfterStripping_Operations_HeaderIsStripped() + { + // Arrange + int callCount = 0; + bool retryAfterWasPresentOnOriginal = false; + bool retryAfterWasPresentAfterPolicy = false; + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (uri.Contains("/operations/")) + { + callCount++; + var response = new MockResponse(200); + response.AddHeader("Retry-After", "1"); + response.SetContent("{}"); + retryAfterWasPresentOnOriginal = response.Headers.Contains("Retry-After"); + return response; + } + return new MockResponse(200); + }); + + // Configure with our policy + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(50), 8); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 0 } + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = RequestMethod.Get; + message.Request.Uri.Reset(new Uri("https://mst.example.com/operations/test-op-123")); + await pipeline.SendAsync(message, CancellationToken.None); + + // Check if Retry-After is present after policy processed the response + retryAfterWasPresentAfterPolicy = message.Response.Headers.Contains("Retry-After"); + + // Assert + Console.WriteLine($"[Operations Header Strip] Calls: {callCount}, " + + $"Retry-After present on original: {retryAfterWasPresentOnOriginal}, " + + $"Retry-After present after policy: {retryAfterWasPresentAfterPolicy}"); + + Assert.That(retryAfterWasPresentOnOriginal, Is.True, "Original response should have Retry-After header"); + Assert.That(retryAfterWasPresentAfterPolicy, Is.False, "Policy should strip Retry-After header from operations response"); + Assert.That(callCount, Is.EqualTo(1), "Should make 1 call (operations don't retry, just strip header)"); + } + + /// + /// Demonstrates the timing difference with and without the policy. + /// Without policy: SDK respects Retry-After: 1 → ~3 seconds + /// With policy: Headers stripped, fast retries → ~600ms + /// + [Test] + public async Task RetryAfterStripping_TimingComparison_WithAndWithoutPolicy() + { + var results = new List<(string Config, long DurationMs, int CallCount)>(); + + // Scenario 1: WITHOUT policy - SDK respects Retry-After: 1 + { + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + callCount++; + if (callCount <= 2) + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return response; + } + var successResponse = new MockResponse(200); + successResponse.SetContent(CreateMessageWithReceipt().Encode()); + return successResponse; + } + return new MockResponse(200); + }); + + // NO policy - SDK will use default retry behavior respecting Retry-After + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(100) } + }; + var pipeline = HttpPipelineBuilder.Build(options); + + var sw = Stopwatch.StartNew(); + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test-entry-123"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("Without Policy", sw.ElapsedMilliseconds, callCount)); + } + + // Scenario 2: WITH policy - Headers stripped, fast retries + { + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + callCount++; + if (callCount <= 2) + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return response; + } + var successResponse = new MockResponse(200); + successResponse.SetContent(CreateMessageWithReceipt().Encode()); + return successResponse; + } + return new MockResponse(200); + }); + + // WITH policy - fast retries + header stripping + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), 8); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(100) } + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + + var sw = Stopwatch.StartNew(); + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/test-entry-123"); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + results.Add(("With Policy", sw.ElapsedMilliseconds, callCount)); + } + + // Output comparison + Console.WriteLine("\n=== RETRY-AFTER STRIPPING TIMING COMPARISON ==="); + Console.WriteLine($"{"Configuration",-20} {"Duration",-15} {"Calls",-10}"); + Console.WriteLine(new string('-', 45)); + + foreach (var r in results) + { + Console.WriteLine($"{r.Config,-20} {r.DurationMs + "ms",-15} {r.CallCount,-10}"); + } + + double speedup = (double)results[0].DurationMs / results[1].DurationMs; + Console.WriteLine($"\nSpeedup: {speedup:F1}x (saved {results[0].DurationMs - results[1].DurationMs}ms)"); + + // Assertions + // Without policy: SDK should respect Retry-After: 1, so ~2+ seconds for 2 retries + Assert.That(results[0].DurationMs, Is.GreaterThanOrEqualTo(1800), + $"Without policy, should take >=1.8s due to Retry-After: 1 headers. Got {results[0].DurationMs}ms"); + + // With policy: Fast retries (~100ms each), so ~200-400ms + Assert.That(results[1].DurationMs, Is.LessThan(600), + $"With policy, should complete in <600ms. Got {results[1].DurationMs}ms"); + + // Speedup should be significant + Assert.That(speedup, Is.GreaterThan(3.0), + $"Policy should provide >3x speedup. Got {speedup:F1}x"); + } + + /// + /// Verifies that non-entries/operations requests are NOT modified by the policy. + /// + [Test] + public async Task RetryAfterStripping_OtherEndpoints_NotModified() + { + // Arrange + var transport = MockTransport.FromMessageCallback(msg => + { + var response = new MockResponse(200); + response.AddHeader("Retry-After", "1"); + response.SetContent("{}"); + return response; + }); + + // Configure with our policy + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(50), 8); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 0 } + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + + // Act - call a different endpoint (not /entries/ or /operations/) + var message = pipeline.CreateMessage(); + message.Request.Method = RequestMethod.Get; + message.Request.Uri.Reset(new Uri("https://mst.example.com/other/endpoint")); + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert - Retry-After should NOT be stripped + bool hasRetryAfter = message.Response.Headers.Contains("Retry-After"); + Console.WriteLine($"[Other Endpoint] Retry-After present: {hasRetryAfter}"); + + Assert.That(hasRetryAfter, Is.True, "Policy should NOT modify responses for other endpoints"); + } + + #endregion } diff --git a/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs similarity index 79% rename from CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs rename to CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs index 6dce65f6..aa5d18e3 100644 --- a/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -13,7 +13,7 @@ namespace CoseSign1.Transparent.MST.Tests; [TestFixture] [Parallelizable(ParallelScope.All)] -public class MstTransactionNotCachedPolicyTests +public class MstPerformanceOptimizationPolicyTests { #region Constructor Tests @@ -21,11 +21,11 @@ public class MstTransactionNotCachedPolicyTests public void Constructor_DefaultValues_SetsExpectedDefaults() { // Act - var policy = new MstTransactionNotCachedPolicy(); + var policy = new MstPerformanceOptimizationPolicy(); // Assert — verify defaults are accessible via the public constants - Assert.That(MstTransactionNotCachedPolicy.DefaultRetryDelay, Is.EqualTo(TimeSpan.FromMilliseconds(250))); - Assert.That(MstTransactionNotCachedPolicy.DefaultMaxRetries, Is.EqualTo(8)); + Assert.That(MstPerformanceOptimizationPolicy.DefaultRetryDelay, Is.EqualTo(TimeSpan.FromMilliseconds(250))); + Assert.That(MstPerformanceOptimizationPolicy.DefaultMaxRetries, Is.EqualTo(8)); Assert.That(policy, Is.Not.Null); } @@ -33,33 +33,33 @@ public void Constructor_DefaultValues_SetsExpectedDefaults() public void Constructor_CustomValues_DoesNotThrow() { // Act & Assert - Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.FromSeconds(1), 3)); + Assert.DoesNotThrow(() => new MstPerformanceOptimizationPolicy(TimeSpan.FromSeconds(1), 3)); } [Test] public void Constructor_ZeroDelay_DoesNotThrow() { - Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.Zero, 5)); + Assert.DoesNotThrow(() => new MstPerformanceOptimizationPolicy(TimeSpan.Zero, 5)); } [Test] public void Constructor_ZeroRetries_DoesNotThrow() { - Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), 0)); + Assert.DoesNotThrow(() => new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), 0)); } [Test] public void Constructor_NegativeDelay_ThrowsArgumentOutOfRange() { Assert.Throws(() => - new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(-1), 3)); + new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(-1), 3)); } [Test] public void Constructor_NegativeRetries_ThrowsArgumentOutOfRange() { Assert.Throws(() => - new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), -1)); + new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), -1)); } #endregion @@ -77,7 +77,7 @@ public async Task ProcessAsync_NonGetRequest_PassesThroughWithoutRetry() return CreateTransactionNotCachedResponse(); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); // Act @@ -99,7 +99,7 @@ public async Task ProcessAsync_GetNonEntriesPath_PassesThroughWithoutRetry() return CreateTransactionNotCachedResponse(); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/operations/abc"); // Act @@ -120,7 +120,7 @@ public async Task ProcessAsync_GetEntriesPath_Non503Status_PassesThroughWithoutR return new MockResponse(200); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act @@ -132,9 +132,10 @@ public async Task ProcessAsync_GetEntriesPath_Non503Status_PassesThroughWithoutR } [Test] - public async Task ProcessAsync_503WithoutCborBody_DoesNotRetry() + public async Task ProcessAsync_503WithoutCborBody_StillRetries() { // Arrange — 503 with empty body (no CBOR problem details) + // With simplified policy, we retry ANY 503 on /entries/ (no CBOR parsing) int callCount = 0; var transport = MockTransport.FromMessageCallback(msg => { @@ -142,20 +143,21 @@ public async Task ProcessAsync_503WithoutCborBody_DoesNotRetry() return new MockResponse(503); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act await pipeline.SendAsync(message, CancellationToken.None); - // Assert — only 1 call (no TransactionNotCached in body) - Assert.That(callCount, Is.EqualTo(1)); + // Assert — 4 calls (1 initial + 3 retries) since we retry any 503 on /entries/ + Assert.That(callCount, Is.EqualTo(4)); } [Test] - public async Task ProcessAsync_503WithDifferentCborError_DoesNotRetry() + public async Task ProcessAsync_503WithDifferentCborError_StillRetries() { // Arrange — 503 with CBOR body containing a different error + // With simplified policy, we retry ANY 503 on /entries/ (no CBOR parsing) int callCount = 0; var transport = MockTransport.FromMessageCallback(msg => { @@ -165,14 +167,14 @@ public async Task ProcessAsync_503WithDifferentCborError_DoesNotRetry() return response; }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act await pipeline.SendAsync(message, CancellationToken.None); - // Assert — only 1 call (not TransactionNotCached) - Assert.That(callCount, Is.EqualTo(1)); + // Assert — 4 calls (1 initial + 3 retries) since we retry any 503 on /entries/ + Assert.That(callCount, Is.EqualTo(4)); } #endregion @@ -194,7 +196,7 @@ public async Task ProcessAsync_TransactionNotCached_RetriesUntilSuccess() return new MockResponse(200); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 5)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 5)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act @@ -217,7 +219,7 @@ public async Task ProcessAsync_TransactionNotCached_ExhaustsRetries_Returns503() }); int maxRetries = 3; - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), maxRetries)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), maxRetries)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act @@ -239,7 +241,7 @@ public async Task ProcessAsync_ZeroMaxRetries_NoRetries() return CreateTransactionNotCachedResponse(); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 0)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 0)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act @@ -266,7 +268,7 @@ public async Task ProcessAsync_TransactionNotCached_InTitle_IsDetected() return new MockResponse(200); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act @@ -294,7 +296,7 @@ public async Task ProcessAsync_TransactionNotCached_CaseInsensitive() return new MockResponse(200); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act @@ -320,7 +322,7 @@ public async Task ProcessAsync_EntriesPath_CaseInsensitive() return new MockResponse(200); }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/ENTRIES/1.234"); // Act @@ -331,9 +333,10 @@ public async Task ProcessAsync_EntriesPath_CaseInsensitive() } [Test] - public async Task ProcessAsync_503WithInvalidCborBody_DoesNotRetry() + public async Task ProcessAsync_503WithInvalidCborBody_StillRetries() { // Arrange — 503 with garbage body (not valid CBOR) + // With simplified policy, we retry ANY 503 on /entries/ (no CBOR parsing) int callCount = 0; var transport = MockTransport.FromMessageCallback(msg => { @@ -343,14 +346,14 @@ public async Task ProcessAsync_503WithInvalidCborBody_DoesNotRetry() return response; }); - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act await pipeline.SendAsync(message, CancellationToken.None); - // Assert — no retry on invalid CBOR - Assert.That(callCount, Is.EqualTo(1)); + // Assert — 4 calls (1 initial + 3 retries) since we retry any 503 on /entries/ + Assert.That(callCount, Is.EqualTo(4)); } #endregion @@ -373,7 +376,7 @@ public void Process_TransactionNotCached_RetriesUntilSuccess() }); transport.ExpectSyncPipeline = true; - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 5)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 5)); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); // Act @@ -396,7 +399,7 @@ public void Process_NonMatchingRequest_DoesNotRetry() }); transport.ExpectSyncPipeline = true; - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); // Act @@ -411,30 +414,30 @@ public void Process_NonMatchingRequest_DoesNotRetry() #region MstClientOptionsExtensions Tests [Test] - public void ConfigureTransactionNotCachedRetry_NullOptions_ThrowsArgumentNullException() + public void ConfigureMstPerformanceOptimizations_NullOptions_ThrowsArgumentNullException() { CodeTransparencyClientOptions? options = null; Assert.Throws(() => - options!.ConfigureTransactionNotCachedRetry()); + options!.ConfigureMstPerformanceOptimizations()); } [Test] - public void ConfigureTransactionNotCachedRetry_DefaultParams_ReturnsSameInstance() + public void ConfigureMstPerformanceOptimizations_DefaultParams_ReturnsSameInstance() { var options = new CodeTransparencyClientOptions(); - var result = options.ConfigureTransactionNotCachedRetry(); + var result = options.ConfigureMstPerformanceOptimizations(); Assert.That(result, Is.SameAs(options)); } [Test] - public void ConfigureTransactionNotCachedRetry_CustomParams_ReturnsSameInstance() + public void ConfigureMstPerformanceOptimizations_CustomParams_ReturnsSameInstance() { var options = new CodeTransparencyClientOptions(); - var result = options.ConfigureTransactionNotCachedRetry( + var result = options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(100), maxRetries: 16); @@ -502,7 +505,7 @@ public async Task PolicyAtPerRetry_FastRetryResolvesBeforeSdkRetryAfterDelay() return new MockResponse(200); }); - var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 5); + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 5); var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -541,7 +544,7 @@ public async Task PolicyAtBeforeTransport_FastRetryResolvesBeforeSdkRetryAfterDe return new MockResponse(200); }); - var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 5); + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 5); var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.BeforeTransport); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -579,7 +582,7 @@ public async Task PolicyAt100msDelay_ResolvesWellUnder500ms() return new MockResponse(200); }); - var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), 5); + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), 5); var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.BeforeTransport); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -599,42 +602,41 @@ public async Task PolicyAt100msDelay_ResolvesWellUnder500ms() } /// - /// When the policy's fast retries all exhaust without success, the 503 propagates back to - /// the SDK's RetryPolicy which applies its own Retry-After delay before retrying. + /// When the policy's fast retries are set to 0, the 503 propagates back with Retry-After stripped. + /// This verifies that header stripping still occurs even without policy retries. /// [Test] - public async Task PolicyExhaustsRetries_SdkRetryTakesOver_WithRetryAfterDelay() + public async Task PolicyWithZeroRetries_StillStripsRetryAfterHeader() { - // Arrange — 503 on first call, then 200 on subsequent calls - // Policy configured with 0 fast retries: passes the 503 straight through to SDK + // Arrange — 503 with Retry-After header + // Policy configured with 0 fast retries: passes the 503 straight through + // but still strips the Retry-After header int callCount = 0; var transport = MockTransport.FromMessageCallback(msg => { callCount++; - if (callCount <= 1) - { - return CreateTransactionNotCachedResponse(); - } - return new MockResponse(200); + return CreateTransactionNotCachedResponse(); // Returns 503 with Retry-After: 1 }); - var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 0); - var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.PerRetry); + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 0); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 0 } // Disable SDK retries to observe policy behavior + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); - var sw = Stopwatch.StartNew(); - // Act await pipeline.SendAsync(message, CancellationToken.None); - sw.Stop(); - - // Assert — SDK retry should have applied Retry-After: 1 delay - Assert.That(message.Response.Status, Is.EqualTo(200)); - Assert.That(callCount, Is.EqualTo(2), "Initial 503 + SDK retry 200 = 2 calls"); - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(800), - $"With 0 fast retries, SDK should fall back to Retry-After delay (~1s), but only waited {sw.ElapsedMilliseconds}ms"); + // Assert — Single call (no policy retries, no SDK retries) + // 503 is returned, but Retry-After header should be stripped + Assert.That(message.Response.Status, Is.EqualTo(503)); + Assert.That(callCount, Is.EqualTo(1), "Should be 1 call with 0 policy retries and 0 SDK retries"); + Assert.That(message.Response.Headers.Contains("Retry-After"), Is.False, + "Retry-After header should be stripped even with 0 policy retries"); } /// @@ -642,7 +644,7 @@ public async Task PolicyExhaustsRetries_SdkRetryTakesOver_WithRetryAfterDelay() /// This tests the actual production registration path. /// [Test] - public async Task ConfigureTransactionNotCachedRetry_PolicyInterceptsBeforeSdkDelay() + public async Task ConfigureMstPerformanceOptimizations_PolicyInterceptsBeforeSdkDelay() { // Arrange int callCount = 0; @@ -657,7 +659,7 @@ public async Task ConfigureTransactionNotCachedRetry_PolicyInterceptsBeforeSdkDe }); var options = new CodeTransparencyClientOptions(); - options.ConfigureTransactionNotCachedRetry( + options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(10), maxRetries: 5); options.Transport = transport; @@ -702,7 +704,7 @@ public async Task PolicyAtPerRetry_Multiple503s_AllResolvedByFastRetry() return new MockResponse(200); }); - var policy = new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(10), 5); + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 5); var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -729,7 +731,7 @@ public async Task PolicyAtPerRetry_Multiple503s_AllResolvedByFastRetry() /// Builds a pipeline with the policy under test inserted before the transport. /// The pipeline has no SDK retry policy — just the custom policy + transport. /// - private static HttpPipeline CreatePipeline(MockTransport transport, MstTransactionNotCachedPolicy policy) + private static HttpPipeline CreatePipeline(MockTransport transport, MstPerformanceOptimizationPolicy policy) { return HttpPipelineBuilder.Build( new TestClientOptions(transport, policy)); @@ -793,7 +795,7 @@ private static byte[] CreateCborProblemDetailsBytesInTitle(string titleValue) /// private sealed class TestClientOptions : ClientOptions { - public TestClientOptions(MockTransport transport, MstTransactionNotCachedPolicy policy) + public TestClientOptions(MockTransport transport, MstPerformanceOptimizationPolicy policy) { Transport = transport; Retry.MaxRetries = 0; // Disable SDK retries to test policy in isolation @@ -810,7 +812,7 @@ private sealed class SdkRetryTestClientOptions : ClientOptions { public SdkRetryTestClientOptions( MockTransport transport, - MstTransactionNotCachedPolicy? policy, + MstPerformanceOptimizationPolicy? policy, HttpPipelinePosition position = HttpPipelinePosition.PerRetry) { Transport = transport; diff --git a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs index 6c7c2dd8..95dd6650 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs @@ -15,9 +15,9 @@ namespace Azure.Security.CodeTransparency; public static class MstClientOptionsExtensions { /// - /// Adds the to the client options pipeline, - /// enabling fast retries for the MST GetEntryStatement 503 / TransactionNotCached - /// response pattern. + /// Adds the to the client options pipeline, + /// enabling fast retries for 503 responses and stripping Retry-After headers + /// for improved MST client performance. /// /// The to configure. /// @@ -32,24 +32,24 @@ public static class MstClientOptionsExtensions /// /// /// This method does not modify the SDK's global - /// on the client options. The fast retry loop runs entirely within the policy and only targets - /// HTTP 503 responses to GET /entries/ requests. All other API calls retain whatever - /// retry behavior the caller has configured (or the SDK defaults). + /// on the client options. The policy performs fast retries for HTTP 503 responses on + /// /entries/ endpoints and strips Retry-After headers from both /entries/ + /// and /operations/ responses. All other API calls pass through unchanged. /// /// /// /// Example: /// /// var options = new CodeTransparencyClientOptions(); - /// options.ConfigureTransactionNotCachedRetry(); // defaults - /// options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100)); // faster - /// options.ConfigureTransactionNotCachedRetry(maxRetries: 16); // longer window + /// options.ConfigureMstPerformanceOptimizations(); // defaults + /// options.ConfigureMstPerformanceOptimizations(TimeSpan.FromMilliseconds(100)); // faster + /// options.ConfigureMstPerformanceOptimizations(maxRetries: 16); // longer window /// /// var client = new CodeTransparencyClient(endpoint, credential, options); /// /// /// - public static CodeTransparencyClientOptions ConfigureTransactionNotCachedRetry( + public static CodeTransparencyClientOptions ConfigureMstPerformanceOptimizations( this CodeTransparencyClientOptions options, TimeSpan? retryDelay = null, int? maxRetries = null) @@ -59,9 +59,9 @@ public static CodeTransparencyClientOptions ConfigureTransactionNotCachedRetry( throw new ArgumentNullException(nameof(options)); } - var policy = new MstTransactionNotCachedPolicy( - retryDelay ?? MstTransactionNotCachedPolicy.DefaultRetryDelay, - maxRetries ?? MstTransactionNotCachedPolicy.DefaultMaxRetries); + var policy = new MstPerformanceOptimizationPolicy( + retryDelay ?? MstPerformanceOptimizationPolicy.DefaultRetryDelay, + maxRetries ?? MstPerformanceOptimizationPolicy.DefaultMaxRetries); options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); return options; diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs new file mode 100644 index 00000000..d9aefaaf --- /dev/null +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; + +/// +/// An Azure SDK pipeline policy that optimizes MST (Microsoft Signing Transparency) +/// client performance by implementing fast retries for 503 responses and stripping +/// Retry-After headers to enable client-controlled timing. +/// +/// +/// +/// Problem: The Azure Code Transparency Service returns HTTP 503 with a +/// Retry-After: 1 header (1 second) when a newly registered entry has not yet +/// propagated to the serving node. The entry typically becomes available in well under +/// 1 second, but the Azure SDK's default respects the server's +/// Retry-After header, causing unnecessary 1-second delays. Additionally, +/// long-running operation (LRO) polling responses include Retry-After headers +/// that override client-configured polling intervals. +/// +/// +/// +/// Solution: This policy: +/// +/// Intercepts 503 responses on /entries/ endpoints and performs fast retries +/// (default: 250 ms intervals, up to 8 retries ≈ 2 seconds). +/// Strips Retry-After headers from all responses to /entries/ and +/// /operations/ endpoints so the SDK uses client-configured delays instead. +/// +/// +/// +/// +/// Scope: Only HTTP 503 responses to GET requests whose URI path contains +/// /entries/ are retried by this policy. Retry-After header stripping applies +/// to both /entries/ and /operations/ endpoints. All other requests pass +/// through unchanged. +/// +/// +/// +/// Pipeline position: Register this policy in the +/// position so it runs inside the SDK's +/// retry loop, directly adjacent to the transport layer. This ensures the policy +/// intercepts responses before any library-added per-retry policies can process them. +/// +/// +/// +/// Usage: +/// +/// var options = new CodeTransparencyClientOptions(); +/// options.AddPolicy(new MstPerformanceOptimizationPolicy(), HttpPipelinePosition.BeforeTransport); +/// var client = new CodeTransparencyClient(endpoint, credential, options); +/// +/// Or use the convenience extension: +/// +/// var options = new CodeTransparencyClientOptions(); +/// options.ConfigureMstPerformanceOptimizations(); +/// +/// +/// +public class MstPerformanceOptimizationPolicy : HttpPipelinePolicy +{ + /// + /// The default interval between fast retry attempts. + /// + public static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromMilliseconds(250); + + /// + /// The default maximum number of fast retry attempts (8 retries × 250 ms ≈ 2 seconds). + /// + public const int DefaultMaxRetries = 8; + + private const int ServiceUnavailableStatusCode = 503; + private const string EntriesPathSegment = "/entries/"; + private const string OperationsPathSegment = "/operations/"; + private const string RetryAfterHeader = "Retry-After"; + + private readonly TimeSpan _retryDelay; + private readonly int _maxRetries; + + /// + /// Initializes a new instance of the class + /// with default retry settings (250 ms delay, 8 retries). + /// + public MstPerformanceOptimizationPolicy() + : this(DefaultRetryDelay, DefaultMaxRetries) + { + } + + /// + /// Initializes a new instance of the class + /// with custom retry settings. + /// + /// The interval to wait between fast retry attempts. + /// The maximum number of fast retry attempts before falling through + /// to the SDK's standard retry logic. + /// + /// Thrown when is negative or is negative. + /// + public MstPerformanceOptimizationPolicy(TimeSpan retryDelay, int maxRetries) + { + if (retryDelay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must not be negative."); + } + + if (maxRetries < 0) + { + throw new ArgumentOutOfRangeException(nameof(maxRetries), "Max retries must not be negative."); + } + + _retryDelay = retryDelay; + _maxRetries = maxRetries; + } + + /// + public override void Process(HttpMessage message, ReadOnlyMemory pipeline) + { + ProcessCore(message, pipeline, isAsync: false).AsTask().GetAwaiter().GetResult(); + } + + /// + public override ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) + { + return ProcessCore(message, pipeline, isAsync: true); + } + + private async ValueTask ProcessCore(HttpMessage message, ReadOnlyMemory pipeline, bool isAsync) + { + if (isAsync) + { + await ProcessNextAsync(message, pipeline).ConfigureAwait(false); + } + else + { + ProcessNext(message, pipeline); + } + + // Strip Retry-After from operations responses (LRO polling) so the SDK uses our configured interval. + if (IsOperationsResponse(message)) + { + StripRetryAfterHeader(message); + return; + } + + // Only process 503 on /entries/ - other responses pass through unchanged. + if (!IsEntriesServiceUnavailableResponse(message)) + { + return; + } + + // 503 on /entries/ - perform fast retries. + // Note: Reusing HttpMessage for retries is safe here because: + // 1. We only retry GET requests (no request body to rewind) + // 2. ProcessNext replaces message.Response with a fresh response + // This matches how Azure SDK's RetryPolicy handles retries internally. + for (int attempt = 0; attempt < _maxRetries; attempt++) + { + if (isAsync) + { + await Task.Delay(_retryDelay, message.CancellationToken).ConfigureAwait(false); + + // Dispose the previous response before issuing a new request. + // ProcessNextAsync will assign a fresh Response to message, so the old one + // must be explicitly released to avoid leaking its content stream and connection. + message.Response?.Dispose(); + await ProcessNextAsync(message, pipeline).ConfigureAwait(false); + } + else + { + message.CancellationToken.ThrowIfCancellationRequested(); + Thread.Sleep(_retryDelay); + + // Dispose the previous response before issuing a new request. + // ProcessNext will assign a fresh Response to message, so the old one + // must be explicitly released to avoid leaking its content stream and connection. + message.Response?.Dispose(); + ProcessNext(message, pipeline); + } + + if (!IsEntriesServiceUnavailableResponse(message)) + { + // Success or different error - strip Retry-After and return. + StripRetryAfterHeader(message); + return; + } + } + + // All fast retries exhausted — strip Retry-After before returning the final 503. + // This prevents the SDK's RetryPolicy from waiting the server-specified delay. + StripRetryAfterHeader(message); + } + + /// + /// Strips the Retry-After header from the response by wrapping it in a filtering response. + /// + private static void StripRetryAfterHeader(HttpMessage message) + { + Response? response = message.Response; + if (response == null || !response.Headers.Contains(RetryAfterHeader)) + { + return; + } + + // Wrap the response in a HeaderFilteringResponse that excludes Retry-After. + message.Response = new HeaderFilteringResponse(response, RetryAfterHeader); + } + + /// + /// Returns if the request URI contains the /entries/ path segment. + /// + private static bool IsEntriesResponse(HttpMessage message) + { + string? requestUri = message.Request.Uri?.ToUri()?.AbsoluteUri; + return requestUri != null && requestUri.IndexOf(EntriesPathSegment, StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Returns if the request URI contains the /operations/ path segment. + /// + private static bool IsOperationsResponse(HttpMessage message) + { + string? requestUri = message.Request.Uri?.ToUri()?.AbsoluteUri; + return requestUri != null && requestUri.IndexOf(OperationsPathSegment, StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Returns if the response is HTTP 503 on a GET request to a /entries/ URI. + /// + private static bool IsEntriesServiceUnavailableResponse(HttpMessage message) + { + if (message.Response == null) + { + return false; + } + + if (message.Response.Status != ServiceUnavailableStatusCode) + { + return false; + } + + if (!message.Request.Method.Equals(RequestMethod.Get)) + { + return false; + } + + return IsEntriesResponse(message); + } +} + +/// +/// A response wrapper that filters out specific headers from the inner response. +/// This allows modifying the apparent headers of a response without modifying the original. +/// +internal sealed class HeaderFilteringResponse : Response +{ + private readonly Response _inner; + private readonly HashSet _excludedHeaders; + + /// + /// Creates a new that wraps the specified response + /// and excludes the specified headers. + /// + /// The response to wrap. + /// Header names to exclude (case-insensitive). + public HeaderFilteringResponse(Response inner, params string[] excludedHeaders) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _excludedHeaders = new HashSet(excludedHeaders, StringComparer.OrdinalIgnoreCase); + } + + /// + public override int Status => _inner.Status; + + /// + public override string ReasonPhrase => _inner.ReasonPhrase; + + /// + public override Stream? ContentStream + { + get => _inner.ContentStream; + set => _inner.ContentStream = value; + } + + /// + public override string ClientRequestId + { + get => _inner.ClientRequestId; + set => _inner.ClientRequestId = value; + } + + /// + public override void Dispose() + { + _inner.Dispose(); + GC.SuppressFinalize(this); + } + + /// + protected override bool TryGetHeader(string name, out string? value) + { + if (_excludedHeaders.Contains(name)) + { + value = null; + return false; + } + + return _inner.Headers.TryGetValue(name, out value); + } + + /// + protected override bool TryGetHeaderValues(string name, out IEnumerable? values) + { + if (_excludedHeaders.Contains(name)) + { + values = null; + return false; + } + + return _inner.Headers.TryGetValues(name, out values); + } + + /// + protected override bool ContainsHeader(string name) + { + if (_excludedHeaders.Contains(name)) + { + return false; + } + + return _inner.Headers.Contains(name); + } + + /// + protected override IEnumerable EnumerateHeaders() + { + foreach (HttpHeader header in _inner.Headers) + { + if (!_excludedHeaders.Contains(header.Name)) + { + yield return header; + } + } + } +} diff --git a/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs b/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs deleted file mode 100644 index 745cbe5c..00000000 --- a/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace CoseSign1.Transparent.MST; - -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Azure; -using Azure.Core; -using Azure.Core.Pipeline; - -/// -/// An Azure SDK pipeline policy that performs aggressive, fast retries exclusively for the -/// MST GetEntryStatement 503 / TransactionNotCached response pattern. -/// -/// -/// -/// Problem: The Azure Code Transparency Service returns HTTP 503 with a -/// Retry-After: 1 header (1 second) when a newly registered entry has not yet -/// propagated to the serving node (TransactionNotCached). The entry typically becomes -/// available in well under 1 second, but the Azure SDK's default -/// respects the server's Retry-After header, causing unnecessary 1-second delays. -/// -/// -/// -/// Solution: This policy intercepts that specific response pattern and performs its own -/// fast retry loop (default: 250 ms intervals, up to 8 retries ≈ 2 seconds) inside -/// the pipeline, before the SDK's standard ever sees the response. -/// -/// -/// -/// Scope: Only HTTP 503 responses to GET requests whose URI path contains -/// /entries/ are retried by this policy. All other requests and response codes -/// pass through with a single ProcessNext call — the SDK's normal retry -/// infrastructure handles them with whatever the caller configured. -/// -/// -/// -/// Pipeline position: Register this policy in the -/// position so it runs inside the SDK's -/// retry loop, directly adjacent to the transport layer. This ensures the policy -/// intercepts the 503 response before any library-added per-retry policies -/// (such as redirect policies) can interfere. Each of the policy's internal retries -/// re-sends the request through the remaining pipeline (transport). If the fast -/// retries succeed, the SDK sees a successful response. If they exhaust without -/// success, the SDK sees the final 503 and applies its own retry logic as usual. -/// -/// -/// -/// Usage: -/// -/// var options = new CodeTransparencyClientOptions(); -/// options.AddPolicy(new MstTransactionNotCachedPolicy(), HttpPipelinePosition.BeforeTransport); -/// var client = new CodeTransparencyClient(endpoint, credential, options); -/// -/// Or use the convenience extension: -/// -/// var options = new CodeTransparencyClientOptions(); -/// options.ConfigureTransactionNotCachedRetry(); -/// -/// -/// -public class MstTransactionNotCachedPolicy : HttpPipelinePolicy -{ - /// - /// The default interval between fast retry attempts. - /// - public static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromMilliseconds(250); - - /// - /// The default maximum number of fast retry attempts (8 retries × 250 ms ≈ 2 seconds). - /// - public const int DefaultMaxRetries = 8; - - private const int ServiceUnavailableStatusCode = 503; - private const string EntriesPathSegment = "/entries/"; - private const string TransactionNotCachedErrorCode = "TransactionNotCached"; - - private readonly TimeSpan _retryDelay; - private readonly int _maxRetries; - - /// - /// Initializes a new instance of the class - /// with default retry settings (250 ms delay, 8 retries). - /// - public MstTransactionNotCachedPolicy() - : this(DefaultRetryDelay, DefaultMaxRetries) - { - } - - /// - /// Initializes a new instance of the class - /// with custom retry settings. - /// - /// The interval to wait between fast retry attempts. - /// The maximum number of fast retry attempts before falling through - /// to the SDK's standard retry logic. - /// - /// Thrown when is negative or is negative. - /// - public MstTransactionNotCachedPolicy(TimeSpan retryDelay, int maxRetries) - { - if (retryDelay < TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must not be negative."); - } - - if (maxRetries < 0) - { - throw new ArgumentOutOfRangeException(nameof(maxRetries), "Max retries must not be negative."); - } - - _retryDelay = retryDelay; - _maxRetries = maxRetries; - } - - /// - public override void Process(HttpMessage message, ReadOnlyMemory pipeline) - { - ProcessCore(message, pipeline, isAsync: false).AsTask().GetAwaiter().GetResult(); - } - - /// - public override ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) - { - return ProcessCore(message, pipeline, isAsync: true); - } - - private async ValueTask ProcessCore(HttpMessage message, ReadOnlyMemory pipeline, bool isAsync) - { - if (isAsync) - { - await ProcessNextAsync(message, pipeline).ConfigureAwait(false); - } - else - { - ProcessNext(message, pipeline); - } - - if (!IsTransactionNotCachedResponse(message)) - { - return; - } - - for (int attempt = 0; attempt < _maxRetries; attempt++) - { - if (isAsync) - { - await Task.Delay(_retryDelay, message.CancellationToken).ConfigureAwait(false); - - // Dispose the previous response before issuing a new request. - // ProcessNextAsync will assign a fresh Response to message, so the old one - // must be explicitly released to avoid leaking its content stream and connection. - message.Response?.Dispose(); - await ProcessNextAsync(message, pipeline).ConfigureAwait(false); - } - else - { - message.CancellationToken.ThrowIfCancellationRequested(); - Thread.Sleep(_retryDelay); - - // Dispose the previous response before issuing a new request. - // ProcessNext will assign a fresh Response to message, so the old one - // must be explicitly released to avoid leaking its content stream and connection. - message.Response?.Dispose(); - ProcessNext(message, pipeline); - } - - if (!IsTransactionNotCachedResponse(message)) - { - return; - } - } - - // All fast retries exhausted — return the final 503 to the outer pipeline. - // The SDK's RetryPolicy will handle it from here (respecting Retry-After as usual). - } - - /// - /// Returns if the response matches the MST TransactionNotCached pattern: - /// HTTP 503 on a GET request to a /entries/ URI with a CBOR problem-details body - /// containing the TransactionNotCached error code. - /// - private static bool IsTransactionNotCachedResponse(HttpMessage message) - { - if (message.Response == null) - { - return false; - } - - if (message.Response.Status != ServiceUnavailableStatusCode) - { - return false; - } - - if (!message.Request.Method.Equals(RequestMethod.Get)) - { - return false; - } - - string? requestUri = message.Request.Uri?.ToUri()?.AbsoluteUri; - if (requestUri == null || requestUri.IndexOf(EntriesPathSegment, StringComparison.OrdinalIgnoreCase) < 0) - { - return false; - } - - // Parse the CBOR problem details body to confirm this is TransactionNotCached. - return HasTransactionNotCachedErrorCode(message.Response); - } - - /// - /// Reads the response body as CBOR problem details and checks whether the error code - /// matches TransactionNotCached in any of the standard fields (Detail, Title, Type) - /// or extension values. - /// - private static bool HasTransactionNotCachedErrorCode(Response response) - { - try - { - // Read the response body. The stream must be seekable so subsequent retries - // (and the SDK's own retry infrastructure) can re-read it. - if (response.ContentStream == null) - { - return false; - } - - if (!response.ContentStream.CanSeek) - { - // Buffer into a seekable MemoryStream so the body is re-readable. - MemoryStream buffer = new(); - response.ContentStream.CopyTo(buffer); - buffer.Position = 0; - response.ContentStream = buffer; - } - - long startPosition = response.ContentStream.Position; - byte[] body; - try - { - response.ContentStream.Position = 0; - using MemoryStream bodyBuffer = new(); - response.ContentStream.CopyTo(bodyBuffer); - body = bodyBuffer.ToArray(); - } - finally - { - // Always rewind so the body remains available for subsequent reads. - response.ContentStream.Position = startPosition; - } - - if (body.Length == 0) - { - return false; - } - - CborProblemDetails? details = CborProblemDetails.TryParse(body); - if (details == null) - { - return false; - } - - // Check standard fields for the error code string. - if (ContainsErrorCode(details.Detail) || - ContainsErrorCode(details.Title) || - ContainsErrorCode(details.Type)) - { - return true; - } - - // Check extension values. - if (details.Extensions?.Any(ext => ext.Value is string strValue && ContainsErrorCode(strValue)) == true) - { - return true; - } - - return false; - } - catch (IOException) - { - // Stream read/seek failure — can't confirm it's TransactionNotCached, don't retry. - return false; - } - catch (ObjectDisposedException) - { - // Response stream was disposed — can't confirm it's TransactionNotCached, don't retry. - return false; - } - catch (NotSupportedException) - { - // Stream doesn't support seek/read — can't confirm it's TransactionNotCached, don't retry. - return false; - } - } - - private static bool ContainsErrorCode(string? value) - { - return value != null - && value.IndexOf(TransactionNotCachedErrorCode, StringComparison.OrdinalIgnoreCase) >= 0; - } -} diff --git a/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs b/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs index 8c221df6..b32ef4dd 100644 --- a/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs +++ b/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs @@ -26,7 +26,7 @@ public static async Task CreateClientAsync(string endpoi { Uri uri = new Uri(endpoint); CodeTransparencyClientOptions clientOptions = new CodeTransparencyClientOptions(); - clientOptions.ConfigureTransactionNotCachedRetry(); + clientOptions.ConfigureMstPerformanceOptimizations(); // Use the specified environment variable name or default to MST_TOKEN string envVarName = tokenEnvVarName ?? "MST_TOKEN"; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce280274..e00eb2e4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -556,7 +556,7 @@ The MST service has specific timing characteristics: ```csharp // Apply both tuning strategies var options = new CodeTransparencyClientOptions(); -options.ConfigureTransactionNotCachedRetry( +options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(100), maxRetries: 8); diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index e3da1353..3cb76e5d 100644 --- a/docs/CoseSign1.Transparent.md +++ b/docs/CoseSign1.Transparent.md @@ -302,27 +302,28 @@ catch (InvalidOperationException ex) } ``` -### TransactionNotCached Fast Retry Policy +### MST Performance Optimization Policy The MST service returns HTTP 503 with `Retry-After: 1` when a newly registered entry hasn't -propagated yet (`TransactionNotCached`). The entry typically becomes available in well under -1 second, but the Azure SDK's default retry respects the server's 1-second `Retry-After` header. +propagated yet. The entry typically becomes available in well under 1 second, but the Azure +SDK's default retry respects the server's 1-second `Retry-After` header. Additionally, LRO +polling responses include `Retry-After` headers that override client-configured polling intervals. -The `MstTransactionNotCachedPolicy` solves this by performing its own fast retry loop -(250 ms intervals, up to 8 retries ≈ 2 seconds) that only targets this specific error. -All other requests pass through untouched. +The `MstPerformanceOptimizationPolicy` addresses these issues by: +1. Performing fast retries for 503 responses on `/entries/` endpoints (250 ms intervals, up to 8 retries) +2. Stripping `Retry-After` headers from `/entries/` and `/operations/` responses so the SDK uses client-configured timing #### Enabling the Policy via Extension Method ```csharp var options = new CodeTransparencyClientOptions(); -options.ConfigureTransactionNotCachedRetry(); // 250ms × 8 retries (default) +options.ConfigureMstPerformanceOptimizations(); // 250ms × 8 retries (default) var client = new CodeTransparencyClient(endpoint, credential, options); ``` #### Custom Retry Settings ```csharp var options = new CodeTransparencyClientOptions(); -options.ConfigureTransactionNotCachedRetry( +options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(100), // faster polling maxRetries: 16); // longer window ``` @@ -333,18 +334,19 @@ using Azure.Core.Pipeline; var options = new CodeTransparencyClientOptions(); options.AddPolicy( - new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(200), 10), + new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(200), 10), HttpPipelinePosition.BeforeTransport); ``` > **Important:** Use `HttpPipelinePosition.BeforeTransport` (not `PerRetry`). This places the > policy directly adjacent to the transport layer, inside the SDK's retry loop, ensuring it > intercepts 503 responses before any library-added per-retry policies can interfere. The -> extension method `ConfigureTransactionNotCachedRetry` handles this automatically. +> extension method `ConfigureMstPerformanceOptimizations` handles this automatically. > This policy does **not** affect the SDK's global `RetryOptions`. The fast retry loop runs -> entirely within the policy and only targets HTTP 503 responses to `GET /entries/` requests -> containing a `TransactionNotCached` CBOR error code. +> entirely within the policy and targets HTTP 503 responses on `/entries/` endpoints. Additionally, +> it strips `Retry-After` headers from `/entries/` and `/operations/` responses to enable +> client-controlled timing instead of server-dictated delays. ### Polling Options From a8691134ca0a162913f9967887b85c7a47e3048e Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Mon, 23 Mar 2026 23:51:01 -0700 Subject: [PATCH 07/16] Strip all Azure SDK retry header variations The Azure SDK checks three headers for retry delays: - Retry-After (standard HTTP, seconds) - retry-after-ms (Azure SDK specific, milliseconds) - x-ms-retry-after-ms (Azure SDK with x-ms prefix, milliseconds) Updated MstPerformanceOptimizationPolicy to strip all three variations. Added parameterized tests verifying each header is correctly stripped. Updated XML and markdown documentation. --- .../MstPerformanceOptimizationPolicyTests.cs | 78 +++++++++++++++++++ .../Extensions/MstClientOptionsExtensions.cs | 10 ++- .../MstPerformanceOptimizationPolicy.cs | 48 +++++++++--- docs/CoseSign1.Transparent.md | 7 +- 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs index aa5d18e3..0a0b4b79 100644 --- a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -639,6 +639,84 @@ public async Task PolicyWithZeroRetries_StillStripsRetryAfterHeader() "Retry-After header should be stripped even with 0 policy retries"); } + /// + /// Verifies that all three Azure SDK Retry-After header variations are stripped. + /// The SDK checks: Retry-After (standard), retry-after-ms, and x-ms-retry-after-ms. + /// + [TestCase("Retry-After", "1", Description = "Standard HTTP header (seconds)")] + [TestCase("retry-after-ms", "1000", Description = "Azure SDK specific (milliseconds)")] + [TestCase("x-ms-retry-after-ms", "1000", Description = "Azure SDK specific with x-ms prefix")] + public async Task PolicyStrips_AllRetryAfterHeaderVariations(string headerName, string headerValue) + { + // Arrange — 503 with the specified retry header + var transport = MockTransport.FromMessageCallback(msg => + { + var response = new MockResponse(503); + response.AddHeader(headerName, headerValue); + response.AddHeader("Content-Type", "application/problem+cbor"); + return response; + }); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 0); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 0 } + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — Header should be stripped + Assert.That(message.Response.Headers.Contains(headerName), Is.False, + $"{headerName} header should be stripped by the policy"); + } + + /// + /// Verifies that when multiple retry headers are present, all are stripped. + /// + [Test] + public async Task PolicyStrips_AllRetryAfterHeaders_WhenMultiplePresent() + { + // Arrange — 503 with all three retry headers + var transport = MockTransport.FromMessageCallback(msg => + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.AddHeader("retry-after-ms", "1000"); + response.AddHeader("x-ms-retry-after-ms", "1000"); + response.AddHeader("Content-Type", "application/problem+cbor"); + return response; + }); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 0); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 0 } + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — All retry headers should be stripped + Assert.Multiple(() => + { + Assert.That(message.Response.Headers.Contains("Retry-After"), Is.False, + "Retry-After header should be stripped"); + Assert.That(message.Response.Headers.Contains("retry-after-ms"), Is.False, + "retry-after-ms header should be stripped"); + Assert.That(message.Response.Headers.Contains("x-ms-retry-after-ms"), Is.False, + "x-ms-retry-after-ms header should be stripped"); + }); + } + /// /// Validates the extension method's registered position intercepts before SDK delay. /// This tests the actual production registration path. diff --git a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs index 95dd6650..6a0dc135 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs @@ -16,8 +16,9 @@ public static class MstClientOptionsExtensions { /// /// Adds the to the client options pipeline, - /// enabling fast retries for 503 responses and stripping Retry-After headers - /// for improved MST client performance. + /// enabling fast retries for 503 responses and stripping retry-related headers + /// (Retry-After, retry-after-ms, x-ms-retry-after-ms) for improved + /// MST client performance. /// /// The to configure. /// @@ -33,8 +34,9 @@ public static class MstClientOptionsExtensions /// /// This method does not modify the SDK's global /// on the client options. The policy performs fast retries for HTTP 503 responses on - /// /entries/ endpoints and strips Retry-After headers from both /entries/ - /// and /operations/ responses. All other API calls pass through unchanged. + /// /entries/ endpoints and strips all retry-related headers (Retry-After, + /// retry-after-ms, x-ms-retry-after-ms) from both /entries/ and + /// /operations/ responses. All other API calls pass through unchanged. /// /// /// diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs index d9aefaaf..e50a0026 100644 --- a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -33,16 +33,17 @@ namespace CoseSign1.Transparent.MST; /// /// Intercepts 503 responses on /entries/ endpoints and performs fast retries /// (default: 250 ms intervals, up to 8 retries ≈ 2 seconds). -/// Strips Retry-After headers from all responses to /entries/ and -/// /operations/ endpoints so the SDK uses client-configured delays instead. +/// Strips all retry-related headers (Retry-After, retry-after-ms, +/// x-ms-retry-after-ms) from responses to /entries/ and /operations/ +/// endpoints so the SDK uses client-configured delays instead. /// /// /// /// /// Scope: Only HTTP 503 responses to GET requests whose URI path contains -/// /entries/ are retried by this policy. Retry-After header stripping applies -/// to both /entries/ and /operations/ endpoints. All other requests pass -/// through unchanged. +/// /entries/ are retried by this policy. Retry header stripping (Retry-After, +/// retry-after-ms, x-ms-retry-after-ms) applies to both /entries/ and +/// /operations/ endpoints. All other requests pass through unchanged. /// /// /// @@ -81,7 +82,17 @@ public class MstPerformanceOptimizationPolicy : HttpPipelinePolicy private const int ServiceUnavailableStatusCode = 503; private const string EntriesPathSegment = "/entries/"; private const string OperationsPathSegment = "/operations/"; - private const string RetryAfterHeader = "Retry-After"; + + /// + /// The set of retry-related headers that the Azure SDK checks for delay information. + /// All three must be stripped to ensure the SDK uses client-configured timing. + /// + private static readonly string[] RetryAfterHeaders = + [ + "Retry-After", // Standard HTTP header (seconds or HTTP-date) + "retry-after-ms", // Azure SDK specific (milliseconds) + "x-ms-retry-after-ms" // Azure SDK specific with x-ms prefix (milliseconds) + ]; private readonly TimeSpan _retryDelay; private readonly int _maxRetries; @@ -200,18 +211,35 @@ private async ValueTask ProcessCore(HttpMessage message, ReadOnlyMemory - /// Strips the Retry-After header from the response by wrapping it in a filtering response. + /// Strips all retry-related headers from the response by wrapping it in a filtering response. + /// This includes Retry-After, retry-after-ms, and x-ms-retry-after-ms. /// private static void StripRetryAfterHeader(HttpMessage message) { Response? response = message.Response; - if (response == null || !response.Headers.Contains(RetryAfterHeader)) + if (response == null) + { + return; + } + + // Check if any retry-related header is present + bool hasRetryHeader = false; + foreach (string header in RetryAfterHeaders) + { + if (response.Headers.Contains(header)) + { + hasRetryHeader = true; + break; + } + } + + if (!hasRetryHeader) { return; } - // Wrap the response in a HeaderFilteringResponse that excludes Retry-After. - message.Response = new HeaderFilteringResponse(response, RetryAfterHeader); + // Wrap the response in a HeaderFilteringResponse that excludes all retry headers. + message.Response = new HeaderFilteringResponse(response, RetryAfterHeaders); } /// diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index 3cb76e5d..f315c719 100644 --- a/docs/CoseSign1.Transparent.md +++ b/docs/CoseSign1.Transparent.md @@ -311,7 +311,7 @@ polling responses include `Retry-After` headers that override client-configured The `MstPerformanceOptimizationPolicy` addresses these issues by: 1. Performing fast retries for 503 responses on `/entries/` endpoints (250 ms intervals, up to 8 retries) -2. Stripping `Retry-After` headers from `/entries/` and `/operations/` responses so the SDK uses client-configured timing +2. Stripping all retry-related headers (`Retry-After`, `retry-after-ms`, `x-ms-retry-after-ms`) from `/entries/` and `/operations/` responses so the SDK uses client-configured timing #### Enabling the Policy via Extension Method ```csharp @@ -345,8 +345,9 @@ options.AddPolicy( > This policy does **not** affect the SDK's global `RetryOptions`. The fast retry loop runs > entirely within the policy and targets HTTP 503 responses on `/entries/` endpoints. Additionally, -> it strips `Retry-After` headers from `/entries/` and `/operations/` responses to enable -> client-controlled timing instead of server-dictated delays. +> it strips all retry-related headers (`Retry-After`, `retry-after-ms`, `x-ms-retry-after-ms`) +> from `/entries/` and `/operations/` responses to enable client-controlled timing instead of +> server-dictated delays. ### Polling Options From 3d3670861e9087f7181df8333d9eaec12e0d290f Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Tue, 24 Mar 2026 00:00:55 -0700 Subject: [PATCH 08/16] Add root cause proof tests confirming Retry-After header impact Definitive proof that Azure SDK respects Retry-After headers: - Without policy: 2025ms for 2x 503 retries (SDK waits 1s per retry) - With policy: 125ms (16x faster - fast retries + headers stripped) New tests in 'Root Cause Confirmation Tests' region: - RootCause_Entries503_SdkRespectsRetryAfterHeader_Causes2SecondDelay - RootCause_Entries503_WithPolicy_ResolvesIn200ms - RootCause_Operations202_SdkRespectsRetryAfterHeader_PolicyStripsIt - RootCause_CombinedScenario_3SecondVs600ms Confirms that both /entries/ (503 retry) AND /operations/ (LRO polling) were affected by server Retry-After headers. --- .../MstEndToEndTimingTests.cs | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs index 561a661f..10e3b22f 100644 --- a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -1386,6 +1386,279 @@ public async Task TrueIntegration_VaryingLroTimes(int lroCompletionTimeMs) #endregion + #region Root Cause Confirmation Tests - Proving Retry-After Impact + + /// + /// DEFINITIVE PROOF: The Azure SDK's RetryPolicy respects Retry-After headers on 503 responses. + /// This test proves that WITHOUT our policy, 2x 503 responses with "Retry-After: 1" cause ~2 seconds delay. + /// This is the root cause for the slow /entries/ GET path. + /// + [Test] + [Category("RootCauseProof")] + public async Task RootCause_Entries503_SdkRespectsRetryAfterHeader_Causes2SecondDelay() + { + // Arrange - 503 responses with Retry-After: 1 header + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + callCount++; + if (callCount <= 2) + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); // 1 second + response.AddHeader("Content-Type", "application/problem+cbor"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return response; + } + var success = new MockResponse(200); + success.SetContent(CreateMessageWithReceipt().Encode()); + return success; + } + return new MockResponse(200); + }); + + // NO custom policy - SDK defaults + var options = new CodeTransparencyClientOptions + { + Transport = transport, + // Small base delay so ONLY Retry-After affects timing + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(1) } + }; + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + // Assert - DEFINITIVE: 2x Retry-After: 1 = ~2 seconds + Console.WriteLine($"[ROOT CAUSE PROOF - entries 503] Duration: {sw.ElapsedMilliseconds}ms, Calls: {callCount}"); + Assert.That(message.Response.Status, Is.EqualTo(200), "Should eventually succeed"); + Assert.That(callCount, Is.EqualTo(3), "Should make 3 calls (2 failures + 1 success)"); + Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(1800), + $"PROOF: SDK respects Retry-After: 1 header. Expected ~2s for 2 retries, got {sw.ElapsedMilliseconds}ms"); + } + + /// + /// DEFINITIVE PROOF: With our policy, the same scenario resolves in ~200ms instead of ~2 seconds. + /// This proves the policy effectively strips Retry-After headers. + /// + [Test] + [Category("RootCauseProof")] + public async Task RootCause_Entries503_WithPolicy_ResolvesIn200ms() + { + // Arrange - same 503 pattern + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + callCount++; + if (callCount <= 2) + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.AddHeader("Content-Type", "application/problem+cbor"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return response; + } + var success = new MockResponse(200); + success.SetContent(CreateMessageWithReceipt().Encode()); + return success; + } + return new MockResponse(200); + }); + + // WITH our policy - fast retries + header stripping + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(50), 8); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(1) } + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); + + var sw = Stopwatch.StartNew(); + await pipeline.SendAsync(message, CancellationToken.None); + sw.Stop(); + + // Assert - DEFINITIVE: ~100-200ms instead of ~2 seconds + Console.WriteLine($"[ROOT CAUSE FIX - entries 503] Duration: {sw.ElapsedMilliseconds}ms, Calls: {callCount}"); + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(callCount, Is.EqualTo(3)); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(400), + $"PROOF: Policy overrides Retry-After. Expected <400ms, got {sw.ElapsedMilliseconds}ms (vs ~2s without policy)"); + } + + /// + /// ROOT CAUSE CONFIRMATION: The /operations/ endpoint (LRO polling) also returns Retry-After headers. + /// Azure SDK's Operation<T>.WaitForCompletionAsync respects these headers, causing slow LRO polling. + /// When the server returns Retry-After: 1 on 202 responses, the SDK waits 1 second between polls + /// REGARDLESS of the polling interval we configure. + /// + /// This test verifies that our policy strips Retry-After from /operations/ responses, + /// allowing our configured polling interval to take effect. + /// + [Test] + [Category("RootCauseProof")] + public async Task RootCause_Operations202_SdkRespectsRetryAfterHeader_PolicyStripsIt() + { + // Arrange - LRO polling response with Retry-After + int pollCount = 0; + bool retryAfterPresentBefore = false; + bool retryAfterPresentAfter = false; + + var transport = MockTransport.FromMessageCallback(msg => + { + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + if (uri.Contains("/operations/")) + { + pollCount++; + var response = new MockResponse(pollCount < 3 ? 202 : 200); // 2x pending, then complete + response.AddHeader("Retry-After", "1"); // Server says "wait 1 second" + response.AddHeader("retry-after-ms", "1000"); // Also the ms variant + response.AddHeader("x-ms-retry-after-ms", "1000"); // And the x-ms variant + response.SetContent("{}"); + retryAfterPresentBefore = response.Headers.Contains("Retry-After"); + return response; + } + return new MockResponse(200); + }); + + // WITH policy to demonstrate header stripping + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(50), 8); + var options = new CodeTransparencyClientOptions + { + Transport = transport, + Retry = { MaxRetries = 0 } // Disable retries, we're testing LRO polling + }; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + + // Act - simulate a single LRO poll request + var message = pipeline.CreateMessage(); + message.Request.Method = RequestMethod.Get; + message.Request.Uri.Reset(new Uri("https://mst.example.com/operations/test-op-123")); + await pipeline.SendAsync(message, CancellationToken.None); + + // Check headers after policy processing + retryAfterPresentAfter = message.Response.Headers.Contains("Retry-After") || + message.Response.Headers.Contains("retry-after-ms") || + message.Response.Headers.Contains("x-ms-retry-after-ms"); + + // Assert + Console.WriteLine($"[ROOT CAUSE - operations LRO] Polls: {pollCount}, " + + $"Retry-After before policy: {retryAfterPresentBefore}, after: {retryAfterPresentAfter}"); + + Assert.That(retryAfterPresentBefore, Is.True, + "Server response should have Retry-After header (this is what causes slow LRO polling)"); + Assert.That(retryAfterPresentAfter, Is.False, + "Policy should strip ALL Retry-After variants from /operations/ responses, " + + "enabling our configured polling interval instead of server-dictated 1s delay"); + } + + /// + /// SUMMARY TEST: Demonstrates the complete impact. + /// Without policy: ~3 seconds (1s LRO first poll + 2x 1s Retry-After on entries) + /// With policy: ~600ms (fast polling + fast retries) + /// + [Test] + [Category("RootCauseProof")] + public async Task RootCause_CombinedScenario_3SecondVs600ms() + { + var results = new List<(string Config, long Ms)>(); + + // Scenario 1: Without policy - expected ~2s+ just for entries + { + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + if (msg.Request.Method == RequestMethod.Get && msg.Request.Uri.ToUri().AbsoluteUri.Contains("/entries/")) + { + callCount++; + if (callCount <= 2) + { + var r = new MockResponse(503); + r.AddHeader("Retry-After", "1"); + r.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return r; + } + var s = new MockResponse(200); + s.SetContent(CreateMessageWithReceipt().Encode()); + return s; + } + return new MockResponse(200); + }); + + var options = new CodeTransparencyClientOptions { Transport = transport }; + options.Retry.MaxRetries = 5; + options.Retry.Delay = TimeSpan.FromMilliseconds(1); + var pipeline = HttpPipelineBuilder.Build(options); + + var sw = Stopwatch.StartNew(); + var msg = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); + await pipeline.SendAsync(msg, CancellationToken.None); + sw.Stop(); + results.Add(("Without Policy", sw.ElapsedMilliseconds)); + } + + // Scenario 2: With policy - expected <400ms + { + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + if (msg.Request.Method == RequestMethod.Get && msg.Request.Uri.ToUri().AbsoluteUri.Contains("/entries/")) + { + callCount++; + if (callCount <= 2) + { + var r = new MockResponse(503); + r.AddHeader("Retry-After", "1"); + r.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return r; + } + var s = new MockResponse(200); + s.SetContent(CreateMessageWithReceipt().Encode()); + return s; + } + return new MockResponse(200); + }); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(50), 8); + var options = new CodeTransparencyClientOptions { Transport = transport }; + options.Retry.MaxRetries = 5; + options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + var pipeline = HttpPipelineBuilder.Build(options); + + var sw = Stopwatch.StartNew(); + var msg = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); + await pipeline.SendAsync(msg, CancellationToken.None); + sw.Stop(); + results.Add(("With Policy", sw.ElapsedMilliseconds)); + } + + // Assert + Console.WriteLine("\n=== ROOT CAUSE IMPACT SUMMARY ==="); + Console.WriteLine($"Without Policy: {results[0].Ms}ms (SDK respects Retry-After: 1)"); + Console.WriteLine($"With Policy: {results[1].Ms}ms (fast retries + header stripped)"); + Console.WriteLine($"Speedup: {(double)results[0].Ms / results[1].Ms:F1}x faster\n"); + + Assert.That(results[0].Ms, Is.GreaterThanOrEqualTo(1800), + "Without policy should take ~2s due to 2x Retry-After: 1"); + Assert.That(results[1].Ms, Is.LessThan(400), + "With policy should take <400ms"); + Assert.That(results[0].Ms, Is.GreaterThan(results[1].Ms * 4), + "Policy should provide at least 4x speedup"); + } + + #endregion + #region Retry-After Header Stripping Tests /// From 082096937ed7321bfa6101bd5d4fc0e24b21955f Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Tue, 24 Mar 2026 00:08:00 -0700 Subject: [PATCH 09/16] fix: Widen all tight timing thresholds for CI stability Comprehensively increase timing tolerances across all timing-sensitive assertions to account for CI runner overhead (especially macOS): - 50ms LRO polling: 700ms -> 1000ms - 100ms GetEntry retry: 500ms -> 700ms - 50ms GetEntry retry: 300ms -> 500ms (was failing at 315ms) - Combined aggressive 50ms: 900ms -> 1200ms Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstEndToEndTimingTests.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs index 10e3b22f..19e29bbb 100644 --- a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -178,8 +178,8 @@ public async Task Tuned_LroPolling_FixedInterval50ms() // Assert Assert.That(result, Is.Not.Null); Console.WriteLine($"[Tuned LRO 50ms] Duration: {sw.ElapsedMilliseconds}ms, Poll count: {pollCount}"); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(700), - $"With 50ms polling, should complete in <700ms. Got {sw.ElapsedMilliseconds}ms"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1000), + $"With 50ms polling, should complete in <1s. Got {sw.ElapsedMilliseconds}ms"); } #endregion @@ -291,8 +291,9 @@ public async Task FullScenario_With_TransactionNotCachedPolicy_ResolvesQuickly() Assert.That(message.Response.Status, Is.EqualTo(200)); Assert.That(getEntryCallCount, Is.EqualTo(3), "Should make exactly 3 calls (2 failures + 1 success)"); // Should complete in ~200-300ms (2x 100ms retry delays + network latency simulation) - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), - $"With custom policy, should resolve in <500ms. Got {sw.ElapsedMilliseconds}ms"); + // Allow 700ms for CI runner overhead + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(700), + $"With custom policy, should resolve in <700ms. Got {sw.ElapsedMilliseconds}ms"); } /// @@ -340,8 +341,8 @@ public async Task FullScenario_With_TransactionNotCachedPolicy_50msDelay() // Assert Console.WriteLine($"[With Policy 50ms] Duration: {sw.ElapsedMilliseconds}ms, GetEntry calls: {getEntryCallCount}"); Assert.That(message.Response.Status, Is.EqualTo(200)); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(300), - $"With 50ms retry delay, should resolve in <300ms. Got {sw.ElapsedMilliseconds}ms"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), + $"With 50ms retry delay, should resolve in <500ms. Got {sw.ElapsedMilliseconds}ms"); } #endregion @@ -513,8 +514,9 @@ public async Task Combined_AggressiveTuning_50ms() Console.WriteLine($"[Combined Aggressive 50ms] Duration: {sw.ElapsedMilliseconds}ms, LRO polls: {lroPollCount}, GetEntry calls: {getEntryCallCount}"); Assert.That(result, Is.Not.Null); // ~400ms LRO + ~100ms for 2 retries = ~500-600ms - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(900), - $"With aggressive 50ms tuning, should complete in <900ms. Got {sw.ElapsedMilliseconds}ms"); + // Allow 1200ms for CI runner overhead + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1200), + $"With aggressive 50ms tuning, should complete in <1.2s. Got {sw.ElapsedMilliseconds}ms"); } #endregion From e8270cbb6785cdd32cd5dfe96488d107b90414a2 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Tue, 24 Mar 2026 00:17:38 -0700 Subject: [PATCH 10/16] fix: Widen remaining timing thresholds for CI stability Address all remaining tight timing assertions from newer tests: - TimingMatrix buffer: +200ms -> +400ms for CI overhead - RootCause entries503: 400ms -> 600ms (got 408ms on CI) - RootCause impact summary: 400ms -> 600ms - RetryAfter stripping: 500ms -> 700ms - Full integration with policy: 600ms -> 800ms Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstEndToEndTimingTests.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs index 19e29bbb..138cd972 100644 --- a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -578,7 +578,7 @@ public async Task TimingMatrix_VariousConfigurations(int lroPollingMs, int retry // Assert int expectedTotalTime = expectedLroTime + (retryDelayMs * failureCount); - int maxAcceptableTime = expectedTotalTime + lroPollingMs + 200; // Add some buffer + int maxAcceptableTime = expectedTotalTime + lroPollingMs + 400; // Add buffer for CI runner overhead Console.WriteLine($"[Matrix LRO:{lroPollingMs}ms Retry:{retryDelayMs}ms Failures:{failureCount}] " + $"Duration: {sw.ElapsedMilliseconds}ms (expected ~{expectedTotalTime}ms), " + @@ -1494,8 +1494,8 @@ public async Task RootCause_Entries503_WithPolicy_ResolvesIn200ms() Console.WriteLine($"[ROOT CAUSE FIX - entries 503] Duration: {sw.ElapsedMilliseconds}ms, Calls: {callCount}"); Assert.That(message.Response.Status, Is.EqualTo(200)); Assert.That(callCount, Is.EqualTo(3)); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(400), - $"PROOF: Policy overrides Retry-After. Expected <400ms, got {sw.ElapsedMilliseconds}ms (vs ~2s without policy)"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(600), + $"PROOF: Policy overrides Retry-After. Expected <600ms, got {sw.ElapsedMilliseconds}ms (vs ~2s without policy)"); } /// @@ -1653,8 +1653,8 @@ public async Task RootCause_CombinedScenario_3SecondVs600ms() Assert.That(results[0].Ms, Is.GreaterThanOrEqualTo(1800), "Without policy should take ~2s due to 2x Retry-After: 1"); - Assert.That(results[1].Ms, Is.LessThan(400), - "With policy should take <400ms"); + Assert.That(results[1].Ms, Is.LessThan(600), + "With policy should take <600ms"); Assert.That(results[0].Ms, Is.GreaterThan(results[1].Ms * 4), "Policy should provide at least 4x speedup"); } @@ -1726,7 +1726,7 @@ public async Task RetryAfterStripping_Entries503_HeaderIsStripped() Assert.That(retryAfterWasPresentAfterPolicy, Is.False, "Policy should strip Retry-After header from final response"); Assert.That(message.Response.Status, Is.EqualTo(200), "Final response should be success"); Assert.That(callCount, Is.EqualTo(3), "Should make 3 calls (2 failures + 1 success)"); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), "With 50ms retries, should complete in <500ms"); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(700), "With 50ms retries, should complete in <700ms"); } /// @@ -1894,8 +1894,9 @@ public async Task RetryAfterStripping_TimingComparison_WithAndWithoutPolicy() $"Without policy, should take >=1.8s due to Retry-After: 1 headers. Got {results[0].DurationMs}ms"); // With policy: Fast retries (~100ms each), so ~200-400ms - Assert.That(results[1].DurationMs, Is.LessThan(600), - $"With policy, should complete in <600ms. Got {results[1].DurationMs}ms"); + // Allow 800ms for CI runner overhead + Assert.That(results[1].DurationMs, Is.LessThan(800), + $"With policy, should complete in <800ms. Got {results[1].DurationMs}ms"); // Speedup should be significant Assert.That(speedup, Is.GreaterThan(3.0), From 5d17df044d2b131aa836b3f07412d22bacd94d75 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Wed, 25 Mar 2026 09:19:02 -0700 Subject: [PATCH 11/16] fix: Move MstPerformanceOptimizationPolicy from BeforeTransport to PerRetry BeforeTransport placed the policy below RequestActivityPolicy (OpenTelemetry tracing), making fast retries invisible in distributed traces (Aspire). The original justification for BeforeTransport was incorrect - CodeTransparencyRedirectPolicy is registered as PerCall, not PerRetry, so there is no interference concern at the PerRetry position. PerRetry places the policy inside the SDK retry loop and above the tracing layer, ensuring retries are visible in telemetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstEndToEndTimingTests.cs | 14 +++--- .../MstPerformanceOptimizationPolicyTests.cs | 50 +++---------------- .../Extensions/MstClientOptionsExtensions.cs | 2 +- .../MstPerformanceOptimizationPolicy.cs | 8 +-- docs/CoseSign1.Transparent.md | 10 ++-- 5 files changed, 23 insertions(+), 61 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs index 138cd972..6130ff01 100644 --- a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -1482,7 +1482,7 @@ public async Task RootCause_Entries503_WithPolicy_ResolvesIn200ms() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(1) } }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateGetEntryMessage(pipeline, "https://mst.example.com/entries/1.234"); @@ -1540,7 +1540,7 @@ public async Task RootCause_Operations202_SdkRespectsRetryAfterHeader_PolicyStri Transport = transport, Retry = { MaxRetries = 0 } // Disable retries, we're testing LRO polling }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); // Act - simulate a single LRO poll request @@ -1635,7 +1635,7 @@ public async Task RootCause_CombinedScenario_3SecondVs600ms() var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(50), 8); var options = new CodeTransparencyClientOptions { Transport = transport }; options.Retry.MaxRetries = 5; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var sw = Stopwatch.StartNew(); @@ -1703,7 +1703,7 @@ public async Task RetryAfterStripping_Entries503_HeaderIsStripped() Transport = transport, Retry = { MaxRetries = 0 } // Disable SDK retries so we can observe policy behavior }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var sw = Stopwatch.StartNew(); @@ -1763,7 +1763,7 @@ public async Task RetryAfterStripping_Operations_HeaderIsStripped() Transport = transport, Retry = { MaxRetries = 0 } }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); // Act @@ -1864,7 +1864,7 @@ public async Task RetryAfterStripping_TimingComparison_WithAndWithoutPolicy() Transport = transport, Retry = { MaxRetries = 5, Delay = TimeSpan.FromMilliseconds(100) } }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var sw = Stopwatch.StartNew(); @@ -1925,7 +1925,7 @@ public async Task RetryAfterStripping_OtherEndpoints_NotModified() Transport = transport, Retry = { MaxRetries = 0 } }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); // Act - call a different endpoint (not /entries/ or /operations/) diff --git a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs index 0a0b4b79..40a16023 100644 --- a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -525,44 +525,6 @@ public async Task PolicyAtPerRetry_FastRetryResolvesBeforeSdkRetryAfterDelay() "If this takes >=1s, the policy is NOT intercepting before the SDK's Retry-After delay."); } - /// - /// Tests the policy at BeforeTransport position as an alternative to PerRetry. - /// BeforeTransport places the policy closest to the transport, inside the retry loop. - /// - [Test] - public async Task PolicyAtBeforeTransport_FastRetryResolvesBeforeSdkRetryAfterDelay() - { - // Arrange - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - if (callCount == 1) - { - return CreateTransactionNotCachedResponse(); - } - return new MockResponse(200); - }); - - var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 5); - var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.BeforeTransport); - var pipeline = HttpPipelineBuilder.Build(options); - var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); - - var sw = Stopwatch.StartNew(); - - // Act - await pipeline.SendAsync(message, CancellationToken.None); - - sw.Stop(); - - // Assert - Assert.That(message.Response.Status, Is.EqualTo(200), "Fast retry should succeed"); - Assert.That(callCount, Is.EqualTo(2), "Should be 2 transport calls (initial 503 + 1 fast retry 200)"); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), - $"Fast retry at BeforeTransport should resolve in <500ms, but took {sw.ElapsedMilliseconds}ms."); - } - /// /// With a 100 ms retry delay the fast retry should still resolve well under 500 ms, /// demonstrating that tighter intervals pull latency down further. @@ -583,7 +545,7 @@ public async Task PolicyAt100msDelay_ResolvesWellUnder500ms() }); var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), 5); - var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.BeforeTransport); + var options = new SdkRetryTestClientOptions(transport, policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -624,7 +586,7 @@ public async Task PolicyWithZeroRetries_StillStripsRetryAfterHeader() Transport = transport, Retry = { MaxRetries = 0 } // Disable SDK retries to observe policy behavior }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -663,7 +625,7 @@ public async Task PolicyStrips_AllRetryAfterHeaderVariations(string headerName, Transport = transport, Retry = { MaxRetries = 0 } }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -698,7 +660,7 @@ public async Task PolicyStrips_AllRetryAfterHeaders_WhenMultiplePresent() Transport = transport, Retry = { MaxRetries = 0 } }; - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); var pipeline = HttpPipelineBuilder.Build(options); var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); @@ -806,7 +768,7 @@ public async Task PolicyAtPerRetry_Multiple503s_AllResolvedByFastRetry() #region Test Helpers /// - /// Builds a pipeline with the policy under test inserted before the transport. + /// Builds a pipeline with the policy under test at PerRetry position. /// The pipeline has no SDK retry policy — just the custom policy + transport. /// private static HttpPipeline CreatePipeline(MockTransport transport, MstPerformanceOptimizationPolicy policy) @@ -877,7 +839,7 @@ public TestClientOptions(MockTransport transport, MstPerformanceOptimizationPoli { Transport = transport; Retry.MaxRetries = 0; // Disable SDK retries to test policy in isolation - AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + AddPolicy(policy, HttpPipelinePosition.PerRetry); } } diff --git a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs index 6a0dc135..6b822c70 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs @@ -65,7 +65,7 @@ public static CodeTransparencyClientOptions ConfigureMstPerformanceOptimizations retryDelay ?? MstPerformanceOptimizationPolicy.DefaultRetryDelay, maxRetries ?? MstPerformanceOptimizationPolicy.DefaultMaxRetries); - options.AddPolicy(policy, HttpPipelinePosition.BeforeTransport); + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); return options; } } diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs index e50a0026..1ac99d16 100644 --- a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -48,16 +48,16 @@ namespace CoseSign1.Transparent.MST; /// /// /// Pipeline position: Register this policy in the -/// position so it runs inside the SDK's -/// retry loop, directly adjacent to the transport layer. This ensures the policy -/// intercepts responses before any library-added per-retry policies can process them. +/// position so it runs inside the SDK's +/// retry loop and above the tracing layer (RequestActivityPolicy). This ensures +/// fast retries are visible in distributed traces (e.g., OpenTelemetry / Aspire). /// /// /// /// Usage: /// /// var options = new CodeTransparencyClientOptions(); -/// options.AddPolicy(new MstPerformanceOptimizationPolicy(), HttpPipelinePosition.BeforeTransport); +/// options.AddPolicy(new MstPerformanceOptimizationPolicy(), HttpPipelinePosition.PerRetry); /// var client = new CodeTransparencyClient(endpoint, credential, options); /// /// Or use the convenience extension: diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index f315c719..bdd37d94 100644 --- a/docs/CoseSign1.Transparent.md +++ b/docs/CoseSign1.Transparent.md @@ -335,13 +335,13 @@ using Azure.Core.Pipeline; var options = new CodeTransparencyClientOptions(); options.AddPolicy( new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(200), 10), - HttpPipelinePosition.BeforeTransport); + HttpPipelinePosition.PerRetry); ``` -> **Important:** Use `HttpPipelinePosition.BeforeTransport` (not `PerRetry`). This places the -> policy directly adjacent to the transport layer, inside the SDK's retry loop, ensuring it -> intercepts 503 responses before any library-added per-retry policies can interfere. The -> extension method `ConfigureMstPerformanceOptimizations` handles this automatically. +> **Important:** Use `HttpPipelinePosition.PerRetry` so the policy runs inside the SDK's retry +> loop and above the tracing layer (`RequestActivityPolicy`). This ensures fast retries are +> visible in distributed traces (e.g., OpenTelemetry / Aspire). The extension method +> `ConfigureMstPerformanceOptimizations` handles this automatically. > This policy does **not** affect the SDK's global `RetryOptions`. The fast retry loop runs > entirely within the policy and targets HTTP 503 responses on `/entries/` endpoints. Additionally, From 45666c0ced5ebf21c028607d4cf1a73ba67de7ee Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Wed, 25 Mar 2026 12:47:16 -0700 Subject: [PATCH 12/16] feat: Add ActivitySource tracing to MstPerformanceOptimizationPolicy Adds distributed tracing via System.Diagnostics.ActivitySource so that accelerated retry behavior is visible in OpenTelemetry/Aspire traces. Activities emitted: - MstPerformanceOptimization.AcceleratedRetry (parent span on 503) Tags: initial_status, max_retries, retry_delay_ms, http.url, resolved_at_attempt, final_status, result - MstPerformanceOptimization.RetryAttempt (child span per retry) Tags: attempt, http.status_code, result (resolved|still_503) ActivitySource name exposed via public const ActivitySourceName for consumers to subscribe with ActivityListener. 6 new tests covering: single retry, multiple retries, exhaustion, non-503 passthrough, /operations/ passthrough, and constant value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstPerformanceOptimizationPolicyTests.cs | 266 +++++++++++++++++- .../MstPerformanceOptimizationPolicy.cs | 36 ++- 2 files changed, 300 insertions(+), 2 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs index 40a16023..a21d39cb 100644 --- a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -3,6 +3,7 @@ namespace CoseSign1.Transparent.MST.Tests; +using System.Collections.Concurrent; using System.Diagnostics; using System.Formats.Cbor; using Azure.Core; @@ -869,4 +870,267 @@ public SdkRetryTestClientOptions( } #endregion -} + + #region ActivitySource Tracing Tests + + /// + /// Verifies that the policy emits an AcceleratedRetry activity with child RetryAttempt + /// activities when it intercepts a 503 on /entries/ and resolves on the 2nd attempt. + /// + [Test] + public async Task ActivitySource_503Resolved_EmitsRetryActivityWithAttempts() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + return response; + } + return new MockResponse(200); + }); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 5); + var options = new TestClientOptions(transport, policy); + HttpPipeline pipeline = HttpPipelineBuilder.Build(options); + + ConcurrentBag collectedActivities = new(); + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => collectedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(callCount, Is.EqualTo(2)); + + List activities = collectedActivities.ToList(); + Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" + && (int?)a.GetTagItem("mst.policy.final_status") == 200 + && (int?)a.GetTagItem("mst.policy.max_retries") == 5 + && (int?)a.GetTagItem("mst.policy.resolved_at_attempt") == 1); + Assert.That(retryActivity, Is.Not.Null, "Should emit an AcceleratedRetry activity"); + Assert.That(retryActivity!.GetTagItem("mst.policy.initial_status"), Is.EqualTo(503)); + Assert.That(retryActivity.GetTagItem("mst.policy.resolved_at_attempt"), Is.EqualTo(1)); + Assert.That(retryActivity.GetTagItem("mst.policy.max_retries"), Is.EqualTo(5)); + Assert.That(retryActivity.GetTagItem("mst.policy.retry_delay_ms"), Is.EqualTo(10.0)); + Assert.That(retryActivity.GetTagItem("http.url"), Does.Contain("/entries/")); + + ActivityTraceId traceId = retryActivity.TraceId; + List attemptActivities = activities.FindAll(a => + a.OperationName == "MstPerformanceOptimization.RetryAttempt" && a.TraceId == traceId); + Assert.That(attemptActivities, Has.Count.EqualTo(1), "Should have 1 retry attempt (resolved on first retry)"); + Assert.That(attemptActivities[0].GetTagItem("mst.policy.attempt"), Is.EqualTo(1)); + Assert.That(attemptActivities[0].GetTagItem("http.status_code"), Is.EqualTo(200)); + Assert.That(attemptActivities[0].GetTagItem("mst.policy.result"), Is.EqualTo("resolved")); + } + + /// + /// Verifies that multiple retry attempts emit individual child activities with correct tags. + /// + [Test] + public async Task ActivitySource_MultipleRetries_EmitsActivityPerAttempt() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 3) + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + return response; + } + return new MockResponse(200); + }); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 5); + var options = new TestClientOptions(transport, policy); + HttpPipeline pipeline = HttpPipelineBuilder.Build(options); + + ConcurrentBag collectedActivities = new(); + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => collectedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(callCount, Is.EqualTo(4)); + + List activities = collectedActivities.ToList(); + Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" + && (int?)a.GetTagItem("mst.policy.final_status") == 200 + && (int?)a.GetTagItem("mst.policy.resolved_at_attempt") == 3); + Assert.That(retryActivity, Is.Not.Null); + + ActivityTraceId traceId = retryActivity!.TraceId; + List attemptActivities = activities + .FindAll(a => a.OperationName == "MstPerformanceOptimization.RetryAttempt" && a.TraceId == traceId) + .OrderBy(a => (int?)a.GetTagItem("mst.policy.attempt") ?? 0) + .ToList(); + Assert.That(attemptActivities, Has.Count.EqualTo(3), "Should have 3 retry attempts"); + + Assert.That(attemptActivities[0].GetTagItem("mst.policy.attempt"), Is.EqualTo(1)); + Assert.That(attemptActivities[0].GetTagItem("mst.policy.result"), Is.EqualTo("still_503")); + Assert.That(attemptActivities[0].GetTagItem("http.status_code"), Is.EqualTo(503)); + + Assert.That(attemptActivities[1].GetTagItem("mst.policy.attempt"), Is.EqualTo(2)); + Assert.That(attemptActivities[1].GetTagItem("mst.policy.result"), Is.EqualTo("still_503")); + + Assert.That(attemptActivities[2].GetTagItem("mst.policy.attempt"), Is.EqualTo(3)); + Assert.That(attemptActivities[2].GetTagItem("mst.policy.result"), Is.EqualTo("resolved")); + Assert.That(attemptActivities[2].GetTagItem("http.status_code"), Is.EqualTo(200)); + } + + /// + /// Verifies that when all retries are exhausted, the parent activity reports "exhausted". + /// + [Test] + public async Task ActivitySource_RetriesExhausted_EmitsExhaustedActivity() + { + // Arrange + var transport = MockTransport.FromMessageCallback(msg => + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + return response; + }); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 3); + var options = new TestClientOptions(transport, policy); + HttpPipeline pipeline = HttpPipelineBuilder.Build(options); + + ConcurrentBag collectedActivities = new(); + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => collectedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert + Assert.That(message.Response.Status, Is.EqualTo(503)); + + List activities = collectedActivities.ToList(); + Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" + && (string?)a.GetTagItem("mst.policy.result") == "exhausted"); + Assert.That(retryActivity, Is.Not.Null); + Assert.That(retryActivity!.GetTagItem("mst.policy.resolved_at_attempt"), Is.EqualTo(0)); + Assert.That(retryActivity.GetTagItem("mst.policy.final_status"), Is.EqualTo(503)); + + ActivityTraceId traceId = retryActivity.TraceId; + List attemptActivities = activities.FindAll(a => + a.OperationName == "MstPerformanceOptimization.RetryAttempt" && a.TraceId == traceId); + Assert.That(attemptActivities, Has.Count.EqualTo(3), "Should have 3 retry attempts (all exhausted)"); + Assert.That(attemptActivities.TrueForAll(a => (string?)a.GetTagItem("mst.policy.result") == "still_503"), Is.True); + } + + /// + /// Verifies that no activities are emitted for non-503 responses or non-/entries/ paths. + /// + [Test] + public async Task ActivitySource_Non503Response_NoActivitiesEmitted() + { + // Arrange + var transport = MockTransport.FromMessageCallback(msg => new MockResponse(200)); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 3); + var options = new TestClientOptions(transport, policy); + HttpPipeline pipeline = HttpPipelineBuilder.Build(options); + + ConcurrentBag collectedActivities = new(); + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => collectedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert + Assert.That(message.Response.Status, Is.EqualTo(200)); + Assert.That(collectedActivities, Is.Empty, "No activities should be emitted for 200 responses"); + } + + /// + /// Verifies that operations responses (LRO polling) do not emit retry activities. + /// + [Test] + public async Task ActivitySource_OperationsPath_NoRetryActivitiesEmitted() + { + // Arrange + var transport = MockTransport.FromMessageCallback(msg => + { + var response = new MockResponse(202); + response.AddHeader("Retry-After", "1"); + return response; + }); + + var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 3); + var options = new TestClientOptions(transport, policy); + HttpPipeline pipeline = HttpPipelineBuilder.Build(options); + + ConcurrentBag collectedActivities = new(); + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => collectedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/operations/op-123"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert + Assert.That(collectedActivities, Is.Empty, "No retry activities for /operations/ paths"); + } + + /// + /// Verifies the constant + /// matches the actual ActivitySource name used. + /// + [Test] + public void ActivitySourceName_IsCorrectValue() + { + Assert.That(MstPerformanceOptimizationPolicy.ActivitySourceName, + Is.EqualTo("CoseSign1.Transparent.MST.PerformanceOptimizationPolicy")); + } + + #endregion +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs index 1ac99d16..b10f2cec 100644 --- a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -5,6 +5,7 @@ namespace CoseSign1.Transparent.MST; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -79,10 +80,17 @@ public class MstPerformanceOptimizationPolicy : HttpPipelinePolicy /// public const int DefaultMaxRetries = 8; + /// + /// The name of the used by this policy for distributed tracing. + /// + public const string ActivitySourceName = "CoseSign1.Transparent.MST.PerformanceOptimizationPolicy"; + private const int ServiceUnavailableStatusCode = 503; private const string EntriesPathSegment = "/entries/"; private const string OperationsPathSegment = "/operations/"; + private static readonly ActivitySource PolicyActivitySource = new(ActivitySourceName); + /// /// The set of retry-related headers that the Azure SDK checks for delay information. /// All three must be stripped to ensure the SDK uses client-configured timing. @@ -168,13 +176,28 @@ private async ValueTask ProcessCore(HttpMessage message, ReadOnlyMemory Date: Fri, 27 Mar 2026 09:08:46 -0700 Subject: [PATCH 13/16] feat: Add Evaluate activity for every policy invocation Adds MstPerformanceOptimization.Evaluate activity that fires on EVERY call through ProcessCore, not just 503 retries. Tags include: http.url, http.request.method, http.response.status_code, mst.policy.is_entries, mst.policy.is_operations, mst.policy.is_503_entries_get, mst.policy.action This will confirm in production whether the policy is being invoked and what URL/method/status it sees for each request. Also adds diagnostic test using real CodeTransparencyClientOptions with SDK retries enabled to verify policy intercepts before RetryPolicy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstPerformanceOptimizationPolicyTests.cs | 94 ++++++++++++++++++- .../MstPerformanceOptimizationPolicy.cs | 24 ++++- 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs index a21d39cb..c204fcda 100644 --- a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -871,6 +871,73 @@ public SdkRetryTestClientOptions( #endregion + #region Diagnostic Pipeline Tests + + /// + /// Diagnostic test using real CodeTransparencyClientOptions with SDK retries enabled + /// to verify our policy intercepts 503 before RetryPolicy applies its delay. + /// + [Test] + public async Task Diagnostic_RealSdkOptions_PolicyInterceptsBeforeRetryPolicy() + { + // Arrange - replicate exact production setup + int transportCallCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + transportCallCount++; + string uri = msg.Request.Uri.ToUri().AbsoluteUri; + Console.WriteLine($" [Transport] Call #{transportCallCount}: {msg.Request.Method} {uri}"); + + if (msg.Request.Method == RequestMethod.Get && uri.Contains("/entries/")) + { + if (transportCallCount == 1) + { + Console.WriteLine($" [Transport] Returning 503 with Retry-After:1"); + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + return response; + } + Console.WriteLine($" [Transport] Returning 200"); + return new MockResponse(200); + } + return new MockResponse(200); + }); + + // Use REAL CodeTransparencyClientOptions (not TestClientOptions) + var options = new CodeTransparencyClientOptions + { + Transport = transport, + }; + options.ConfigureMstPerformanceOptimizations( + retryDelay: TimeSpan.FromMilliseconds(50), + maxRetries: 8); + + HttpPipeline pipeline = HttpPipelineBuilder.Build(options); + HttpMessage message = pipeline.CreateMessage(); + message.Request.Method = RequestMethod.Get; + message.Request.Uri.Reset(new Uri( + "https://esrp-cts-dev.confidential-ledger.azure.com/entries/702.1048242/statement?api-version=2025-01-31-preview")); + + Console.WriteLine("[Test] Sending request through pipeline..."); + Stopwatch sw = Stopwatch.StartNew(); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + sw.Stop(); + Console.WriteLine($"[Test] Completed in {sw.ElapsedMilliseconds}ms, Status: {message.Response.Status}"); + Console.WriteLine($"[Test] Transport was called {transportCallCount} times"); + + // Assert + Assert.That(message.Response.Status, Is.EqualTo(200)); + // If our policy works: ~50ms delay. If SDK RetryPolicy handles it: ~800ms+ + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500), + $"Policy should intercept 503 and retry in ~50ms, not wait for SDK retry (~800ms). Took {sw.ElapsedMilliseconds}ms"); + Assert.That(transportCallCount, Is.EqualTo(2), "Transport should be called exactly twice (503, then 200)"); + } + + #endregion + #region ActivitySource Tracing Tests /// @@ -1082,7 +1149,17 @@ public async Task ActivitySource_Non503Response_NoActivitiesEmitted() // Assert Assert.That(message.Response.Status, Is.EqualTo(200)); - Assert.That(collectedActivities, Is.Empty, "No activities should be emitted for 200 responses"); + List activities = collectedActivities.ToList(); + Activity? evalActivity = activities.Find(a => + a.OperationName == "MstPerformanceOptimization.Evaluate" + && (string?)a.GetTagItem("mst.policy.action") == "passthrough" + && (int?)a.GetTagItem("http.response.status_code") == 200); + Assert.That(evalActivity, Is.Not.Null, "Should emit an Evaluate activity"); + + List retryActivities = activities + .FindAll(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" + && a.TraceId == evalActivity!.TraceId); + Assert.That(retryActivities, Is.Empty, "No retry activities should be emitted for 200 responses"); } /// @@ -1117,8 +1194,19 @@ public async Task ActivitySource_OperationsPath_NoRetryActivitiesEmitted() // Act await pipeline.SendAsync(message, CancellationToken.None); - // Assert - Assert.That(collectedActivities, Is.Empty, "No retry activities for /operations/ paths"); + // Assert - find the Evaluate activity for THIS test's /operations/ path + List activities = collectedActivities.ToList(); + Activity? evalActivity = activities.Find(a => + a.OperationName == "MstPerformanceOptimization.Evaluate" + && (bool?)a.GetTagItem("mst.policy.is_operations") == true); + Assert.That(evalActivity, Is.Not.Null, "Should emit an Evaluate activity for /operations/"); + Assert.That(evalActivity!.GetTagItem("mst.policy.action"), Is.EqualTo("strip_operations_headers")); + + // No AcceleratedRetry activities should share this trace + List retryActivities = activities + .FindAll(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" + && a.TraceId == evalActivity.TraceId); + Assert.That(retryActivities, Is.Empty, "No retry activities for /operations/ paths"); } /// diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs index b10f2cec..d34e751c 100644 --- a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -163,9 +163,24 @@ private async ValueTask ProcessCore(HttpMessage message, ReadOnlyMemory Date: Fri, 27 Mar 2026 09:20:17 -0700 Subject: [PATCH 14/16] refactor: Use parent activity scoping for test isolation; fix CodeQL suggestions Test refactor: - Add TestActivitySource and CreateScopedActivityCollector helper - Each ActivitySource test starts a parent Activity to scope collection - Remove brittle tag-based and traceId-based filtering workarounds - Simple Find(a => a.OperationName == ...) now suffices for all assertions CodeQL fixes (github-advanced-security[bot] comments): - StripRetryAfterHeader: foreach+if -> RetryAfterHeaders.Any(...) - EnumerateHeaders: foreach+yield+if -> .Where(...) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstPerformanceOptimizationPolicyTests.cs | 159 ++++++++++-------- .../MstPerformanceOptimizationPolicy.cs | 19 +-- 2 files changed, 90 insertions(+), 88 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs index c204fcda..581444f5 100644 --- a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -940,6 +940,51 @@ public async Task Diagnostic_RealSdkOptions_PolicyInterceptsBeforeRetryPolicy() #region ActivitySource Tracing Tests + private static readonly ActivitySource TestActivitySource = new("MstPerformanceOptimizationPolicyTests"); + + // Ensure TestActivitySource activities are always sampled + private static readonly ActivityListener TestParentListener = CreateTestParentListener(); + private static ActivityListener CreateTestParentListener() + { + ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == "MstPerformanceOptimizationPolicyTests", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [OneTimeTearDown] + public void CleanupActivitySourceResources() + { + TestParentListener.Dispose(); + TestActivitySource.Dispose(); + } + + private static (ActivityListener Listener, ConcurrentBag Activities) CreateScopedActivityCollector(Activity parentActivity) + { + ActivityTraceId traceId = parentActivity.TraceId; + ConcurrentBag collected = new(); + ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, + Sample = (ref ActivityCreationOptions options) => + options.Parent.TraceId == traceId + ? ActivitySamplingResult.AllDataAndRecorded + : ActivitySamplingResult.None, + ActivityStopped = activity => + { + if (activity.TraceId == traceId) + { + collected.Add(activity); + } + } + }; + ActivitySource.AddActivityListener(listener); + return (listener, collected); + } + /// /// Verifies that the policy emits an AcceleratedRetry activity with child RetryAttempt /// activities when it intercepts a 503 on /entries/ and resolves on the 2nd attempt. @@ -948,6 +993,9 @@ public async Task Diagnostic_RealSdkOptions_PolicyInterceptsBeforeRetryPolicy() public async Task ActivitySource_503Resolved_EmitsRetryActivityWithAttempts() { // Arrange + using Activity parentActivity = TestActivitySource.StartActivity("Test.503Resolved")!; + (ActivityListener scopedListener, ConcurrentBag collectedActivities) = CreateScopedActivityCollector(parentActivity); + int callCount = 0; var transport = MockTransport.FromMessageCallback(msg => { @@ -965,15 +1013,6 @@ public async Task ActivitySource_503Resolved_EmitsRetryActivityWithAttempts() var options = new TestClientOptions(transport, policy); HttpPipeline pipeline = HttpPipelineBuilder.Build(options); - ConcurrentBag collectedActivities = new(); - using ActivityListener listener = new() - { - ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => collectedActivities.Add(activity) - }; - ActivitySource.AddActivityListener(listener); - HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); // Act @@ -984,24 +1023,23 @@ public async Task ActivitySource_503Resolved_EmitsRetryActivityWithAttempts() Assert.That(callCount, Is.EqualTo(2)); List activities = collectedActivities.ToList(); - Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" - && (int?)a.GetTagItem("mst.policy.final_status") == 200 - && (int?)a.GetTagItem("mst.policy.max_retries") == 5 - && (int?)a.GetTagItem("mst.policy.resolved_at_attempt") == 1); + Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry"); Assert.That(retryActivity, Is.Not.Null, "Should emit an AcceleratedRetry activity"); Assert.That(retryActivity!.GetTagItem("mst.policy.initial_status"), Is.EqualTo(503)); Assert.That(retryActivity.GetTagItem("mst.policy.resolved_at_attempt"), Is.EqualTo(1)); Assert.That(retryActivity.GetTagItem("mst.policy.max_retries"), Is.EqualTo(5)); Assert.That(retryActivity.GetTagItem("mst.policy.retry_delay_ms"), Is.EqualTo(10.0)); Assert.That(retryActivity.GetTagItem("http.url"), Does.Contain("/entries/")); + Assert.That(retryActivity.GetTagItem("mst.policy.final_status"), Is.EqualTo(200)); - ActivityTraceId traceId = retryActivity.TraceId; List attemptActivities = activities.FindAll(a => - a.OperationName == "MstPerformanceOptimization.RetryAttempt" && a.TraceId == traceId); + a.OperationName == "MstPerformanceOptimization.RetryAttempt"); Assert.That(attemptActivities, Has.Count.EqualTo(1), "Should have 1 retry attempt (resolved on first retry)"); Assert.That(attemptActivities[0].GetTagItem("mst.policy.attempt"), Is.EqualTo(1)); Assert.That(attemptActivities[0].GetTagItem("http.status_code"), Is.EqualTo(200)); Assert.That(attemptActivities[0].GetTagItem("mst.policy.result"), Is.EqualTo("resolved")); + + scopedListener.Dispose(); } /// @@ -1011,6 +1049,9 @@ public async Task ActivitySource_503Resolved_EmitsRetryActivityWithAttempts() public async Task ActivitySource_MultipleRetries_EmitsActivityPerAttempt() { // Arrange + using Activity parentActivity = TestActivitySource.StartActivity("Test.MultipleRetries")!; + (ActivityListener scopedListener, ConcurrentBag collectedActivities) = CreateScopedActivityCollector(parentActivity); + int callCount = 0; var transport = MockTransport.FromMessageCallback(msg => { @@ -1028,15 +1069,6 @@ public async Task ActivitySource_MultipleRetries_EmitsActivityPerAttempt() var options = new TestClientOptions(transport, policy); HttpPipeline pipeline = HttpPipelineBuilder.Build(options); - ConcurrentBag collectedActivities = new(); - using ActivityListener listener = new() - { - ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => collectedActivities.Add(activity) - }; - ActivitySource.AddActivityListener(listener); - HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); // Act @@ -1047,14 +1079,11 @@ public async Task ActivitySource_MultipleRetries_EmitsActivityPerAttempt() Assert.That(callCount, Is.EqualTo(4)); List activities = collectedActivities.ToList(); - Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" - && (int?)a.GetTagItem("mst.policy.final_status") == 200 - && (int?)a.GetTagItem("mst.policy.resolved_at_attempt") == 3); + Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry"); Assert.That(retryActivity, Is.Not.Null); - ActivityTraceId traceId = retryActivity!.TraceId; List attemptActivities = activities - .FindAll(a => a.OperationName == "MstPerformanceOptimization.RetryAttempt" && a.TraceId == traceId) + .FindAll(a => a.OperationName == "MstPerformanceOptimization.RetryAttempt") .OrderBy(a => (int?)a.GetTagItem("mst.policy.attempt") ?? 0) .ToList(); Assert.That(attemptActivities, Has.Count.EqualTo(3), "Should have 3 retry attempts"); @@ -1069,6 +1098,8 @@ public async Task ActivitySource_MultipleRetries_EmitsActivityPerAttempt() Assert.That(attemptActivities[2].GetTagItem("mst.policy.attempt"), Is.EqualTo(3)); Assert.That(attemptActivities[2].GetTagItem("mst.policy.result"), Is.EqualTo("resolved")); Assert.That(attemptActivities[2].GetTagItem("http.status_code"), Is.EqualTo(200)); + + scopedListener.Dispose(); } /// @@ -1078,6 +1109,9 @@ public async Task ActivitySource_MultipleRetries_EmitsActivityPerAttempt() public async Task ActivitySource_RetriesExhausted_EmitsExhaustedActivity() { // Arrange + using Activity parentActivity = TestActivitySource.StartActivity("Test.RetriesExhausted")!; + (ActivityListener scopedListener, ConcurrentBag collectedActivities) = CreateScopedActivityCollector(parentActivity); + var transport = MockTransport.FromMessageCallback(msg => { var response = new MockResponse(503); @@ -1089,15 +1123,6 @@ public async Task ActivitySource_RetriesExhausted_EmitsExhaustedActivity() var options = new TestClientOptions(transport, policy); HttpPipeline pipeline = HttpPipelineBuilder.Build(options); - ConcurrentBag collectedActivities = new(); - using ActivityListener listener = new() - { - ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => collectedActivities.Add(activity) - }; - ActivitySource.AddActivityListener(listener); - HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); // Act @@ -1107,17 +1132,18 @@ public async Task ActivitySource_RetriesExhausted_EmitsExhaustedActivity() Assert.That(message.Response.Status, Is.EqualTo(503)); List activities = collectedActivities.ToList(); - Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" - && (string?)a.GetTagItem("mst.policy.result") == "exhausted"); + Activity? retryActivity = activities.Find(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry"); Assert.That(retryActivity, Is.Not.Null); - Assert.That(retryActivity!.GetTagItem("mst.policy.resolved_at_attempt"), Is.EqualTo(0)); + Assert.That(retryActivity!.GetTagItem("mst.policy.result"), Is.EqualTo("exhausted")); + Assert.That(retryActivity.GetTagItem("mst.policy.resolved_at_attempt"), Is.EqualTo(0)); Assert.That(retryActivity.GetTagItem("mst.policy.final_status"), Is.EqualTo(503)); - ActivityTraceId traceId = retryActivity.TraceId; List attemptActivities = activities.FindAll(a => - a.OperationName == "MstPerformanceOptimization.RetryAttempt" && a.TraceId == traceId); + a.OperationName == "MstPerformanceOptimization.RetryAttempt"); Assert.That(attemptActivities, Has.Count.EqualTo(3), "Should have 3 retry attempts (all exhausted)"); Assert.That(attemptActivities.TrueForAll(a => (string?)a.GetTagItem("mst.policy.result") == "still_503"), Is.True); + + scopedListener.Dispose(); } /// @@ -1127,21 +1153,15 @@ public async Task ActivitySource_RetriesExhausted_EmitsExhaustedActivity() public async Task ActivitySource_Non503Response_NoActivitiesEmitted() { // Arrange + using Activity parentActivity = TestActivitySource.StartActivity("Test.Non503Response")!; + (ActivityListener scopedListener, ConcurrentBag collectedActivities) = CreateScopedActivityCollector(parentActivity); + var transport = MockTransport.FromMessageCallback(msg => new MockResponse(200)); var policy = new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(10), 3); var options = new TestClientOptions(transport, policy); HttpPipeline pipeline = HttpPipelineBuilder.Build(options); - ConcurrentBag collectedActivities = new(); - using ActivityListener listener = new() - { - ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => collectedActivities.Add(activity) - }; - ActivitySource.AddActivityListener(listener); - HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/test-id/statement"); // Act @@ -1151,15 +1171,16 @@ public async Task ActivitySource_Non503Response_NoActivitiesEmitted() Assert.That(message.Response.Status, Is.EqualTo(200)); List activities = collectedActivities.ToList(); Activity? evalActivity = activities.Find(a => - a.OperationName == "MstPerformanceOptimization.Evaluate" - && (string?)a.GetTagItem("mst.policy.action") == "passthrough" - && (int?)a.GetTagItem("http.response.status_code") == 200); + a.OperationName == "MstPerformanceOptimization.Evaluate"); Assert.That(evalActivity, Is.Not.Null, "Should emit an Evaluate activity"); + Assert.That(evalActivity!.GetTagItem("mst.policy.action"), Is.EqualTo("passthrough")); + Assert.That(evalActivity.GetTagItem("http.response.status_code"), Is.EqualTo(200)); List retryActivities = activities - .FindAll(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" - && a.TraceId == evalActivity!.TraceId); + .FindAll(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry"); Assert.That(retryActivities, Is.Empty, "No retry activities should be emitted for 200 responses"); + + scopedListener.Dispose(); } /// @@ -1169,6 +1190,9 @@ public async Task ActivitySource_Non503Response_NoActivitiesEmitted() public async Task ActivitySource_OperationsPath_NoRetryActivitiesEmitted() { // Arrange + using Activity parentActivity = TestActivitySource.StartActivity("Test.OperationsPath")!; + (ActivityListener scopedListener, ConcurrentBag collectedActivities) = CreateScopedActivityCollector(parentActivity); + var transport = MockTransport.FromMessageCallback(msg => { var response = new MockResponse(202); @@ -1180,33 +1204,24 @@ public async Task ActivitySource_OperationsPath_NoRetryActivitiesEmitted() var options = new TestClientOptions(transport, policy); HttpPipeline pipeline = HttpPipelineBuilder.Build(options); - ConcurrentBag collectedActivities = new(); - using ActivityListener listener = new() - { - ShouldListenTo = source => source.Name == MstPerformanceOptimizationPolicy.ActivitySourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => collectedActivities.Add(activity) - }; - ActivitySource.AddActivityListener(listener); - HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/operations/op-123"); // Act await pipeline.SendAsync(message, CancellationToken.None); - // Assert - find the Evaluate activity for THIS test's /operations/ path + // Assert List activities = collectedActivities.ToList(); Activity? evalActivity = activities.Find(a => - a.OperationName == "MstPerformanceOptimization.Evaluate" - && (bool?)a.GetTagItem("mst.policy.is_operations") == true); + a.OperationName == "MstPerformanceOptimization.Evaluate"); Assert.That(evalActivity, Is.Not.Null, "Should emit an Evaluate activity for /operations/"); Assert.That(evalActivity!.GetTagItem("mst.policy.action"), Is.EqualTo("strip_operations_headers")); + Assert.That(evalActivity.GetTagItem("mst.policy.is_operations"), Is.EqualTo(true)); - // No AcceleratedRetry activities should share this trace List retryActivities = activities - .FindAll(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry" - && a.TraceId == evalActivity.TraceId); + .FindAll(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry"); Assert.That(retryActivities, Is.Empty, "No retry activities for /operations/ paths"); + + scopedListener.Dispose(); } /// diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs index d34e751c..c9a03990 100644 --- a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -7,6 +7,7 @@ namespace CoseSign1.Transparent.MST; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Azure; @@ -273,15 +274,7 @@ private static void StripRetryAfterHeader(HttpMessage message) } // Check if any retry-related header is present - bool hasRetryHeader = false; - foreach (string header in RetryAfterHeaders) - { - if (response.Headers.Contains(header)) - { - hasRetryHeader = true; - break; - } - } + bool hasRetryHeader = RetryAfterHeaders.Any(header => response.Headers.Contains(header)); if (!hasRetryHeader) { @@ -420,12 +413,6 @@ protected override bool ContainsHeader(string name) /// protected override IEnumerable EnumerateHeaders() { - foreach (HttpHeader header in _inner.Headers) - { - if (!_excludedHeaders.Contains(header.Name)) - { - yield return header; - } - } + return _inner.Headers.Where(header => !_excludedHeaders.Contains(header.Name)); } } From f6f2a5abdefa653ed64c1d46b80c44fce1576036 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Fri, 27 Mar 2026 15:56:57 -0700 Subject: [PATCH 15/16] docs: Document both optimizations required for MST performance The Azure SDK introduces two separate 1-second delays that must both be addressed for optimal MST performance: 1. LRO polling: SDK's internal FixedDelayWithNoJitterStrategy clamps intervals to Max(suggestedDelay, 1s). Fix: use DelayStrategy on MstPollingOptions instead of PollingInterval. 2. Entry retrieval: 503 responses include Retry-After:1 which the SDK's RetryPolicy respects. Fix: ConfigureMstPerformanceOptimizations() on CodeTransparencyClientOptions BEFORE constructing the client. Added prominent best-practice section with complete code example showing both optimizations configured together, with clear warning that options must be passed to the CodeTransparencyClient constructor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/CoseSign1.Transparent.md | 121 ++++++++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 20 deletions(-) diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index bdd37d94..85f48544 100644 --- a/docs/CoseSign1.Transparent.md +++ b/docs/CoseSign1.Transparent.md @@ -83,6 +83,7 @@ public class TransparencyExample #### Example: Creating a Transparent Message with Polling Options ```csharp +using Azure.Core; using Azure.Security.CodeTransparency; using CoseSign1.Transparent; using CoseSign1.Transparent.Extensions; @@ -90,13 +91,21 @@ using CoseSign1.Transparent.MST; public class TransparencyWithPollingExample { - public async Task CreateTransparentMessageWithPolling(CodeTransparencyClient client) + public async Task CreateTransparentMessageWithPolling(Uri endpoint, AzureKeyCredential credential) { CoseSign1Message message = new CoseSign1Message { Content = new byte[] { 1, 2, 3 } }; + // Configure client options with pipeline policy for fast /entries/ retries + var clientOptions = new CodeTransparencyClientOptions(); + clientOptions.ConfigureMstPerformanceOptimizations(); + + // Create client WITH configured options (pipeline is frozen at construction) + var client = new CodeTransparencyClient(endpoint, credential, clientOptions); + + // Use DelayStrategy (not PollingInterval) for sub-second LRO polling var pollingOptions = new MstPollingOptions { - PollingInterval = TimeSpan.FromMilliseconds(250) + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100)) }; // Extension method with polling options — no extra 'using' needed @@ -302,24 +311,73 @@ catch (InvalidOperationException ex) } ``` -### MST Performance Optimization Policy +### MST Performance Optimization + +The Azure Code Transparency Service has two default behaviors that introduce unnecessary +1-second delays. Without optimization, each signing operation can take **2–3 seconds longer** +than necessary: + +| Delay Source | Default Behavior | Impact | +|---|---|---| +| **LRO polling** | Azure SDK's `Operation.WaitForCompletionAsync` uses an internal `FixedDelayWithNoJitterStrategy` that clamps any interval to `Max(suggestedDelay, 1 second)`, and the server returns `Retry-After: 1` headers that override client-configured intervals. | ~1 second per poll instead of ~100 ms | +| **Entry retrieval** | `GetEntryStatementAsync` returns HTTP 503 with `Retry-After: 1` when the entry hasn't propagated yet (typically available in < 100 ms). The SDK's default `RetryPolicy` respects this header and waits 1 second before retrying. | ~1 second delay on entry fetch | -The MST service returns HTTP 503 with `Retry-After: 1` when a newly registered entry hasn't -propagated yet. The entry typically becomes available in well under 1 second, but the Azure -SDK's default retry respects the server's 1-second `Retry-After` header. Additionally, LRO -polling responses include `Retry-After` headers that override client-configured polling intervals. +#### ⚡ Best Practice: Configure Both Optimizations Together -The `MstPerformanceOptimizationPolicy` addresses these issues by: -1. Performing fast retries for 503 responses on `/entries/` endpoints (250 ms intervals, up to 8 retries) -2. Stripping all retry-related headers (`Retry-After`, `retry-after-ms`, `x-ms-retry-after-ms`) from `/entries/` and `/operations/` responses so the SDK uses client-configured timing +To achieve optimal performance, you **must** configure both: +1. A custom `DelayStrategy` on `MstPollingOptions` to bypass the SDK's 1-second LRO polling floor +2. The `MstPerformanceOptimizationPolicy` on `CodeTransparencyClientOptions` to fast-retry 503 responses + +> **Critical:** The `CodeTransparencyClientOptions` with the policy must be passed to the +> `CodeTransparencyClient` constructor — that is when the HTTP pipeline is built. Adding the +> policy to options *after* the client is constructed has no effect on that client's pipeline. -#### Enabling the Policy via Extension Method ```csharp -var options = new CodeTransparencyClientOptions(); -options.ConfigureMstPerformanceOptimizations(); // 250ms × 8 retries (default) -var client = new CodeTransparencyClient(endpoint, credential, options); +using Azure.Core; +using Azure.Security.CodeTransparency; +using CoseSign1.Transparent.MST; + +// 1. Configure client options with the performance optimization pipeline policy +var clientOptions = new CodeTransparencyClientOptions(); +clientOptions.ConfigureMstPerformanceOptimizations(); // fast-retries 503s on /entries/ + +// 2. Create the client WITH the configured options (pipeline is built here) +var client = new CodeTransparencyClient(endpoint, credential, clientOptions); + +// 3. Configure polling options with a custom DelayStrategy for fast LRO polling +var pollingOptions = new MstPollingOptions +{ + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100)) +}; + +// 4. Create the transparency service with both the client and options +var service = new MstTransparencyService( + client, + verificationOptions, + clientOptions, // used by VerifyTransparentStatement (static method creates new clients) + pollingOptions); + +// Now both MakeTransparentAsync and VerifyTransparencyAsync are optimized +CoseSign1Message result = await service.MakeTransparentAsync(message); ``` +> **Without these optimizations:** A typical signing + entry retrieval takes ~3.5 seconds. +> **With both optimizations:** The same operation completes in ~1.0–1.5 seconds. + +#### What Each Optimization Does + +**`MstPollingOptions.DelayStrategy`** — Controls LRO polling interval: +- The Azure SDK's internal delay strategy enforces a 1-second floor on polling intervals +- Using `DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100))` bypasses this + by passing the strategy directly to `Operation.WaitForCompletionAsync(DelayStrategy, ...)` +- This takes precedence over `PollingInterval` if both are set + +**`ConfigureMstPerformanceOptimizations()`** — Adds a pipeline policy that: +- Intercepts HTTP 503 responses on `/entries/` endpoints and retries at 250 ms intervals + (up to 8 retries ≈ 2 seconds) before the SDK's `RetryPolicy` sees the error +- Strips `Retry-After`, `retry-after-ms`, and `x-ms-retry-after-ms` headers from both + `/entries/` and `/operations/` responses to prevent the SDK from overriding client timing + #### Custom Retry Settings ```csharp var options = new CodeTransparencyClientOptions(); @@ -343,18 +401,41 @@ options.AddPolicy( > visible in distributed traces (e.g., OpenTelemetry / Aspire). The extension method > `ConfigureMstPerformanceOptimizations` handles this automatically. -> This policy does **not** affect the SDK's global `RetryOptions`. The fast retry loop runs -> entirely within the policy and targets HTTP 503 responses on `/entries/` endpoints. Additionally, -> it strips all retry-related headers (`Retry-After`, `retry-after-ms`, `x-ms-retry-after-ms`) -> from `/entries/` and `/operations/` responses to enable client-controlled timing instead of -> server-dictated delays. +#### Diagnostic Tracing + +The policy emits `System.Diagnostics.Activity` spans via an `ActivitySource` named +`CoseSign1.Transparent.MST.PerformanceOptimizationPolicy`. When an OpenTelemetry collector +or Aspire dashboard is configured, you will see: + +- **`MstPerformanceOptimization.Evaluate`** — emitted for every request through the policy, + with tags showing the URL, method, status code, and action taken +- **`MstPerformanceOptimization.AcceleratedRetry`** — emitted when a 503 triggers the fast + retry loop, with tags for attempt count, delay, and final status +- **`MstPerformanceOptimization.RetryAttempt`** — emitted per retry attempt within the loop ### Polling Options The `MstPollingOptions` class controls how `MstTransparencyService` polls for completed receipt registrations after `CreateEntryAsync`. -#### Fixed Interval Polling +> **Important:** Use `DelayStrategy` (not `PollingInterval`) for sub-second polling. +> The Azure SDK's internal `FixedDelayWithNoJitterStrategy` clamps any interval to +> `Max(suggestedDelay, 1 second)`, making `PollingInterval` values below 1 second ineffective. +> `DelayStrategy` bypasses this by passing directly to +> `Operation.WaitForCompletionAsync(DelayStrategy, CancellationToken)`. + +#### Recommended: Fixed Delay Strategy (sub-second polling) +```csharp +using Azure.Core; + +var pollingOptions = new MstPollingOptions +{ + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100)) +}; +var service = new MstTransparencyService(client, pollingOptions); +``` + +#### Fixed Interval Polling (1 second minimum) ```csharp var pollingOptions = new MstPollingOptions { From 930cafa14b571cdee804c8e1c8185fa49003db11 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Fri, 27 Mar 2026 23:19:13 -0700 Subject: [PATCH 16/16] fix: Standardize on http.response.status_code tag (OTEL convention) Rename http.status_code to http.response.status_code on RetryAttempt activities to match OpenTelemetry semantic conventions and be consistent with the Evaluate activity tags. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MstPerformanceOptimizationPolicyTests.cs | 6 +++--- .../MstPerformanceOptimizationPolicy.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs index 581444f5..10f75574 100644 --- a/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -1036,7 +1036,7 @@ public async Task ActivitySource_503Resolved_EmitsRetryActivityWithAttempts() a.OperationName == "MstPerformanceOptimization.RetryAttempt"); Assert.That(attemptActivities, Has.Count.EqualTo(1), "Should have 1 retry attempt (resolved on first retry)"); Assert.That(attemptActivities[0].GetTagItem("mst.policy.attempt"), Is.EqualTo(1)); - Assert.That(attemptActivities[0].GetTagItem("http.status_code"), Is.EqualTo(200)); + Assert.That(attemptActivities[0].GetTagItem("http.response.status_code"), Is.EqualTo(200)); Assert.That(attemptActivities[0].GetTagItem("mst.policy.result"), Is.EqualTo("resolved")); scopedListener.Dispose(); @@ -1090,14 +1090,14 @@ public async Task ActivitySource_MultipleRetries_EmitsActivityPerAttempt() Assert.That(attemptActivities[0].GetTagItem("mst.policy.attempt"), Is.EqualTo(1)); Assert.That(attemptActivities[0].GetTagItem("mst.policy.result"), Is.EqualTo("still_503")); - Assert.That(attemptActivities[0].GetTagItem("http.status_code"), Is.EqualTo(503)); + Assert.That(attemptActivities[0].GetTagItem("http.response.status_code"), Is.EqualTo(503)); Assert.That(attemptActivities[1].GetTagItem("mst.policy.attempt"), Is.EqualTo(2)); Assert.That(attemptActivities[1].GetTagItem("mst.policy.result"), Is.EqualTo("still_503")); Assert.That(attemptActivities[2].GetTagItem("mst.policy.attempt"), Is.EqualTo(3)); Assert.That(attemptActivities[2].GetTagItem("mst.policy.result"), Is.EqualTo("resolved")); - Assert.That(attemptActivities[2].GetTagItem("http.status_code"), Is.EqualTo(200)); + Assert.That(attemptActivities[2].GetTagItem("http.response.status_code"), Is.EqualTo(200)); scopedListener.Dispose(); } diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs index c9a03990..2c485fc7 100644 --- a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -238,7 +238,7 @@ private async ValueTask ProcessCore(HttpMessage message, ReadOnlyMemory