diff --git a/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs new file mode 100644 index 00000000..6130ff01 --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/MstEndToEndTimingTests.cs @@ -0,0 +1,1945 @@ +// 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(1000), + $"With 50ms polling, should complete in <1s. 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 MstPerformanceOptimizationPolicy + /// + /// 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 MstPerformanceOptimizationPolicy. + /// 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.ConfigureMstPerformanceOptimizations( + 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) + // Allow 700ms for CI runner overhead + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(700), + $"With custom policy, should resolve in <700ms. 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.ConfigureMstPerformanceOptimizations( + 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(500), + $"With 50ms retry delay, should resolve in <500ms. 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 + // 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 + + #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 + 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), " + + $"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 MstPerformanceOptimizationPolicy) + /// + /// 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.ConfigureMstPerformanceOptimizations( + 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 + // 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"); + } + + /// + /// 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.ConfigureMstPerformanceOptimizations( + 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 + // 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"); + } + + /// + /// 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.ConfigureMstPerformanceOptimizations(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.ConfigureMstPerformanceOptimizations(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.ConfigureMstPerformanceOptimizations(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(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; + 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.ConfigureMstPerformanceOptimizations(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.ConfigureMstPerformanceOptimizations(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 + 350), + $"Tuned LRO should complete within 350ms of actual time. Got {results[1].TotalMs}ms for {lroCompletionTimeMs}ms LRO"); + } + + #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.PerRetry); + 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(600), + $"PROOF: Policy overrides Retry-After. Expected <600ms, 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.PerRetry); + 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.PerRetry); + 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(600), + "With policy should take <600ms"); + 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 + + /// + /// 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.PerRetry); + 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(700), "With 50ms retries, should complete in <700ms"); + } + + /// + /// 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.PerRetry); + 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.PerRetry); + 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 + // 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), + $"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.PerRetry); + 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/MstPerformanceOptimizationPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs new file mode 100644 index 00000000..10f75574 --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/MstPerformanceOptimizationPolicyTests.cs @@ -0,0 +1,1239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST.Tests; + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Formats.Cbor; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Core.TestCommon; +using Azure.Security.CodeTransparency; +using CoseSign1.Transparent.MST; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class MstPerformanceOptimizationPolicyTests +{ + #region Constructor Tests + + [Test] + public void Constructor_DefaultValues_SetsExpectedDefaults() + { + // Act + var policy = new MstPerformanceOptimizationPolicy(); + + // Assert — verify defaults are accessible via the public constants + Assert.That(MstPerformanceOptimizationPolicy.DefaultRetryDelay, Is.EqualTo(TimeSpan.FromMilliseconds(250))); + Assert.That(MstPerformanceOptimizationPolicy.DefaultMaxRetries, Is.EqualTo(8)); + Assert.That(policy, Is.Not.Null); + } + + [Test] + public void Constructor_CustomValues_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => new MstPerformanceOptimizationPolicy(TimeSpan.FromSeconds(1), 3)); + } + + [Test] + public void Constructor_ZeroDelay_DoesNotThrow() + { + Assert.DoesNotThrow(() => new MstPerformanceOptimizationPolicy(TimeSpan.Zero, 5)); + } + + [Test] + public void Constructor_ZeroRetries_DoesNotThrow() + { + Assert.DoesNotThrow(() => new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), 0)); + } + + [Test] + public void Constructor_NegativeDelay_ThrowsArgumentOutOfRange() + { + Assert.Throws(() => + new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(-1), 3)); + } + + [Test] + public void Constructor_NegativeRetries_ThrowsArgumentOutOfRange() + { + Assert.Throws(() => + new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), -1)); + } + + #endregion + + #region ProcessAsync — Non-Matching Requests (Pass-Through) + + [Test] + public async Task ProcessAsync_NonGetRequest_PassesThroughWithoutRetry() + { + // Arrange — POST to /entries/ returning 503 with TransactionNotCached body + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only 1 call, no retries (POST is not matched) + Assert.That(callCount, Is.EqualTo(1)); + Assert.That(message.Response.Status, Is.EqualTo(503)); + } + + [Test] + public async Task ProcessAsync_GetNonEntriesPath_PassesThroughWithoutRetry() + { + // Arrange — GET to /operations/ returning 503 with TransactionNotCached body + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/operations/abc"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only 1 call + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public async Task ProcessAsync_GetEntriesPath_Non503Status_PassesThroughWithoutRetry() + { + // Arrange — GET to /entries/ returning 200 + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return new MockResponse(200); + }); + + 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 + Assert.That(callCount, Is.EqualTo(1)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + 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 => + { + callCount++; + return new MockResponse(503); + }); + + 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 — 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_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 => + { + callCount++; + var response = new MockResponse(503); + response.SetContent(CreateCborProblemDetailsBytes("ServiceTooBusy")); + return response; + }); + + 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 — 4 calls (1 initial + 3 retries) since we retry any 503 on /entries/ + Assert.That(callCount, Is.EqualTo(4)); + } + + #endregion + + #region ProcessAsync — Matching Requests (Retry Behavior) + + [Test] + public async Task ProcessAsync_TransactionNotCached_RetriesUntilSuccess() + { + // Arrange — first 2 calls return 503/TransactionNotCached, third returns 200 + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 5)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — 3 calls total (1 initial + 2 retries before success) + Assert.That(callCount, Is.EqualTo(3)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public async Task ProcessAsync_TransactionNotCached_ExhaustsRetries_Returns503() + { + // Arrange — always return 503/TransactionNotCached + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + int maxRetries = 3; + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), maxRetries)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — 1 initial + maxRetries retries = 4 total calls + Assert.That(callCount, Is.EqualTo(1 + maxRetries)); + Assert.That(message.Response.Status, Is.EqualTo(503)); + } + + [Test] + public async Task ProcessAsync_ZeroMaxRetries_NoRetries() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 0)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only the initial call, no retries + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public async Task ProcessAsync_TransactionNotCached_InTitle_IsDetected() + { + // Arrange — error code in Title field instead of Detail + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + var response = new MockResponse(503); + response.SetContent(CreateCborProblemDetailsBytesInTitle("TransactionNotCached")); + return response; + } + return new MockResponse(200); + }); + + 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 — retried and succeeded on second call + Assert.That(callCount, Is.EqualTo(2)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public async Task ProcessAsync_TransactionNotCached_CaseInsensitive() + { + // Arrange — lowercase error code + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + var response = new MockResponse(503); + response.SetContent(CreateCborProblemDetailsBytes("transactionnotcached")); + return response; + } + return new MockResponse(200); + }); + + 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 + Assert.That(callCount, Is.EqualTo(2)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public async Task ProcessAsync_EntriesPath_CaseInsensitive() + { + // Arrange — uppercase ENTRIES in path + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + 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 — path matching is case-insensitive + Assert.That(callCount, Is.EqualTo(2)); + } + + [Test] + 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 => + { + callCount++; + var response = new MockResponse(503); + response.SetContent(new byte[] { 0xFF, 0xFE, 0x00 }); + return response; + }); + + 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 — 4 calls (1 initial + 3 retries) since we retry any 503 on /entries/ + Assert.That(callCount, Is.EqualTo(4)); + } + + #endregion + + #region Process (Sync) + + [Test] + public void Process_TransactionNotCached_RetriesUntilSuccess() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + transport.ExpectSyncPipeline = true; + + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 5)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + pipeline.Send(message, CancellationToken.None); + + // Assert + Assert.That(callCount, Is.EqualTo(3)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public void Process_NonMatchingRequest_DoesNotRetry() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + transport.ExpectSyncPipeline = true; + + var pipeline = CreatePipeline(transport, new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); + + // Act + pipeline.Send(message, CancellationToken.None); + + // Assert + Assert.That(callCount, Is.EqualTo(1)); + } + + #endregion + + #region MstClientOptionsExtensions Tests + + [Test] + public void ConfigureMstPerformanceOptimizations_NullOptions_ThrowsArgumentNullException() + { + CodeTransparencyClientOptions? options = null; + + Assert.Throws(() => + options!.ConfigureMstPerformanceOptimizations()); + } + + [Test] + public void ConfigureMstPerformanceOptimizations_DefaultParams_ReturnsSameInstance() + { + var options = new CodeTransparencyClientOptions(); + + var result = options.ConfigureMstPerformanceOptimizations(); + + Assert.That(result, Is.SameAs(options)); + } + + [Test] + public void ConfigureMstPerformanceOptimizations_CustomParams_ReturnsSameInstance() + { + var options = new CodeTransparencyClientOptions(); + + var result = options.ConfigureMstPerformanceOptimizations( + retryDelay: TimeSpan.FromMilliseconds(100), + maxRetries: 16); + + Assert.That(result, Is.SameAs(options)); + } + + #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 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"); + + 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."); + } + + /// + /// 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 MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(100), 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 — 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 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 PolicyWithZeroRetries_StillStripsRetryAfterHeader() + { + // 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++; + return CreateTransactionNotCachedResponse(); // Returns 503 with Retry-After: 1 + }); + + 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.PerRetry); + 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 — 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"); + } + + /// + /// 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.PerRetry); + 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.PerRetry); + 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. + /// + [Test] + public async Task ConfigureMstPerformanceOptimizations_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.ConfigureMstPerformanceOptimizations( + 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 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"); + + 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 + + /// + /// 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) + { + return HttpPipelineBuilder.Build( + new TestClientOptions(transport, policy)); + } + + /// + /// Creates an HttpMessage with the given method and URI, ready to send through the pipeline. + /// + private static HttpMessage CreateHttpMessage(HttpPipeline pipeline, RequestMethod method, string uri) + { + var message = pipeline.CreateMessage(); + message.Request.Method = method; + message.Request.Uri.Reset(new Uri(uri)); + return message; + } + + /// + /// Creates a mock 503 response with a CBOR problem-details body containing TransactionNotCached. + /// + private static MockResponse CreateTransactionNotCachedResponse() + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return response; + } + + /// + /// Creates CBOR problem details bytes with the error code in the Detail field (key -4). + /// + 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 CBOR problem details bytes with the error code in the Title field (key -2). + /// + private static byte[] CreateCborProblemDetailsBytesInTitle(string titleValue) + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); // status + writer.WriteInt32(503); + writer.WriteInt32(-2); // title + writer.WriteTextString(titleValue); + writer.WriteEndMap(); + return writer.Encode(); + } + + /// + /// Minimal ClientOptions subclass for building a pipeline with our custom policy and a mock transport. + /// Disables the SDK's default retry to isolate the policy's behavior. + /// + private sealed class TestClientOptions : ClientOptions + { + public TestClientOptions(MockTransport transport, MstPerformanceOptimizationPolicy policy) + { + Transport = transport; + Retry.MaxRetries = 0; // Disable SDK retries to test policy in isolation + AddPolicy(policy, HttpPipelinePosition.PerRetry); + } + } + + /// + /// 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, + MstPerformanceOptimizationPolicy? 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); + } + } + } + + #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 + + 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. + /// + [Test] + 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 => + { + 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); + + 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"); + 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)); + + List attemptActivities = activities.FindAll(a => + 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.response.status_code"), Is.EqualTo(200)); + Assert.That(attemptActivities[0].GetTagItem("mst.policy.result"), Is.EqualTo("resolved")); + + scopedListener.Dispose(); + } + + /// + /// Verifies that multiple retry attempts emit individual child activities with correct tags. + /// + [Test] + 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 => + { + 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); + + 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"); + Assert.That(retryActivity, Is.Not.Null); + + List attemptActivities = activities + .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"); + + 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.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.response.status_code"), Is.EqualTo(200)); + + scopedListener.Dispose(); + } + + /// + /// Verifies that when all retries are exhausted, the parent activity reports "exhausted". + /// + [Test] + 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); + 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); + + 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"); + Assert.That(retryActivity, Is.Not.Null); + 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)); + + List attemptActivities = activities.FindAll(a => + 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(); + } + + /// + /// Verifies that no activities are emitted for non-503 responses or non-/entries/ paths. + /// + [Test] + 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); + + 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)); + List activities = collectedActivities.ToList(); + Activity? evalActivity = activities.Find(a => + 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"); + Assert.That(retryActivities, Is.Empty, "No retry activities should be emitted for 200 responses"); + + scopedListener.Dispose(); + } + + /// + /// Verifies that operations responses (LRO polling) do not emit retry activities. + /// + [Test] + 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); + 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); + + HttpMessage message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/operations/op-123"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert + List activities = collectedActivities.ToList(); + Activity? evalActivity = activities.Find(a => + 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)); + + List retryActivities = activities + .FindAll(a => a.OperationName == "MstPerformanceOptimization.AcceleratedRetry"); + Assert.That(retryActivities, Is.Empty, "No retry activities for /operations/ paths"); + + scopedListener.Dispose(); + } + + /// + /// 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.Tests/MstTransactionNotCachedPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs deleted file mode 100644 index f8a6d301..00000000 --- a/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs +++ /dev/null @@ -1,524 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace CoseSign1.Transparent.MST.Tests; - -using System.Formats.Cbor; -using Azure.Core; -using Azure.Core.Pipeline; -using Azure.Core.TestCommon; -using Azure.Security.CodeTransparency; -using CoseSign1.Transparent.MST; - -[TestFixture] -[Parallelizable(ParallelScope.All)] -public class MstTransactionNotCachedPolicyTests -{ - #region Constructor Tests - - [Test] - public void Constructor_DefaultValues_SetsExpectedDefaults() - { - // Act - var policy = new MstTransactionNotCachedPolicy(); - - // 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(policy, Is.Not.Null); - } - - [Test] - public void Constructor_CustomValues_DoesNotThrow() - { - // Act & Assert - Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.FromSeconds(1), 3)); - } - - [Test] - public void Constructor_ZeroDelay_DoesNotThrow() - { - Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.Zero, 5)); - } - - [Test] - public void Constructor_ZeroRetries_DoesNotThrow() - { - Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), 0)); - } - - [Test] - public void Constructor_NegativeDelay_ThrowsArgumentOutOfRange() - { - Assert.Throws(() => - new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(-1), 3)); - } - - [Test] - public void Constructor_NegativeRetries_ThrowsArgumentOutOfRange() - { - Assert.Throws(() => - new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), -1)); - } - - #endregion - - #region ProcessAsync — Non-Matching Requests (Pass-Through) - - [Test] - public async Task ProcessAsync_NonGetRequest_PassesThroughWithoutRetry() - { - // Arrange — POST to /entries/ returning 503 with TransactionNotCached body - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - return CreateTransactionNotCachedResponse(); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); - var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); - - // Act - await pipeline.SendAsync(message, CancellationToken.None); - - // Assert — only 1 call, no retries (POST is not matched) - Assert.That(callCount, Is.EqualTo(1)); - Assert.That(message.Response.Status, Is.EqualTo(503)); - } - - [Test] - public async Task ProcessAsync_GetNonEntriesPath_PassesThroughWithoutRetry() - { - // Arrange — GET to /operations/ returning 503 with TransactionNotCached body - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - return CreateTransactionNotCachedResponse(); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); - var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/operations/abc"); - - // Act - await pipeline.SendAsync(message, CancellationToken.None); - - // Assert — only 1 call - Assert.That(callCount, Is.EqualTo(1)); - } - - [Test] - public async Task ProcessAsync_GetEntriesPath_Non503Status_PassesThroughWithoutRetry() - { - // Arrange — GET to /entries/ returning 200 - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - return new MockResponse(200); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(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 - Assert.That(callCount, Is.EqualTo(1)); - Assert.That(message.Response.Status, Is.EqualTo(200)); - } - - [Test] - public async Task ProcessAsync_503WithoutCborBody_DoesNotRetry() - { - // Arrange — 503 with empty body (no CBOR problem details) - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - return new MockResponse(503); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(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)); - } - - [Test] - public async Task ProcessAsync_503WithDifferentCborError_DoesNotRetry() - { - // Arrange — 503 with CBOR body containing a different error - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - var response = new MockResponse(503); - response.SetContent(CreateCborProblemDetailsBytes("ServiceTooBusy")); - return response; - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(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)); - } - - #endregion - - #region ProcessAsync — Matching Requests (Retry Behavior) - - [Test] - public async Task ProcessAsync_TransactionNotCached_RetriesUntilSuccess() - { - // Arrange — first 2 calls return 503/TransactionNotCached, third returns 200 - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - if (callCount <= 2) - { - return CreateTransactionNotCachedResponse(); - } - return new MockResponse(200); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 5)); - var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); - - // Act - await pipeline.SendAsync(message, CancellationToken.None); - - // Assert — 3 calls total (1 initial + 2 retries before success) - Assert.That(callCount, Is.EqualTo(3)); - Assert.That(message.Response.Status, Is.EqualTo(200)); - } - - [Test] - public async Task ProcessAsync_TransactionNotCached_ExhaustsRetries_Returns503() - { - // Arrange — always return 503/TransactionNotCached - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - return CreateTransactionNotCachedResponse(); - }); - - int maxRetries = 3; - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), maxRetries)); - var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); - - // Act - await pipeline.SendAsync(message, CancellationToken.None); - - // Assert — 1 initial + maxRetries retries = 4 total calls - Assert.That(callCount, Is.EqualTo(1 + maxRetries)); - Assert.That(message.Response.Status, Is.EqualTo(503)); - } - - [Test] - public async Task ProcessAsync_ZeroMaxRetries_NoRetries() - { - // Arrange - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - return CreateTransactionNotCachedResponse(); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 0)); - var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); - - // Act - await pipeline.SendAsync(message, CancellationToken.None); - - // Assert — only the initial call, no retries - Assert.That(callCount, Is.EqualTo(1)); - } - - [Test] - public async Task ProcessAsync_TransactionNotCached_InTitle_IsDetected() - { - // Arrange — error code in Title field instead of Detail - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - if (callCount <= 1) - { - var response = new MockResponse(503); - response.SetContent(CreateCborProblemDetailsBytesInTitle("TransactionNotCached")); - return response; - } - return new MockResponse(200); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(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 — retried and succeeded on second call - Assert.That(callCount, Is.EqualTo(2)); - Assert.That(message.Response.Status, Is.EqualTo(200)); - } - - [Test] - public async Task ProcessAsync_TransactionNotCached_CaseInsensitive() - { - // Arrange — lowercase error code - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - if (callCount <= 1) - { - var response = new MockResponse(503); - response.SetContent(CreateCborProblemDetailsBytes("transactionnotcached")); - return response; - } - return new MockResponse(200); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(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 - Assert.That(callCount, Is.EqualTo(2)); - Assert.That(message.Response.Status, Is.EqualTo(200)); - } - - [Test] - public async Task ProcessAsync_EntriesPath_CaseInsensitive() - { - // Arrange — uppercase ENTRIES in path - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - if (callCount <= 1) - { - return CreateTransactionNotCachedResponse(); - } - return new MockResponse(200); - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(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 — path matching is case-insensitive - Assert.That(callCount, Is.EqualTo(2)); - } - - [Test] - public async Task ProcessAsync_503WithInvalidCborBody_DoesNotRetry() - { - // Arrange — 503 with garbage body (not valid CBOR) - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - var response = new MockResponse(503); - response.SetContent(new byte[] { 0xFF, 0xFE, 0x00 }); - return response; - }); - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(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)); - } - - #endregion - - #region Process (Sync) - - [Test] - public void Process_TransactionNotCached_RetriesUntilSuccess() - { - // Arrange - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - if (callCount <= 2) - { - return CreateTransactionNotCachedResponse(); - } - return new MockResponse(200); - }); - transport.ExpectSyncPipeline = true; - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 5)); - var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); - - // Act - pipeline.Send(message, CancellationToken.None); - - // Assert - Assert.That(callCount, Is.EqualTo(3)); - Assert.That(message.Response.Status, Is.EqualTo(200)); - } - - [Test] - public void Process_NonMatchingRequest_DoesNotRetry() - { - // Arrange - int callCount = 0; - var transport = MockTransport.FromMessageCallback(msg => - { - callCount++; - return CreateTransactionNotCachedResponse(); - }); - transport.ExpectSyncPipeline = true; - - var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); - var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); - - // Act - pipeline.Send(message, CancellationToken.None); - - // Assert - Assert.That(callCount, Is.EqualTo(1)); - } - - #endregion - - #region MstClientOptionsExtensions Tests - - [Test] - public void ConfigureTransactionNotCachedRetry_NullOptions_ThrowsArgumentNullException() - { - CodeTransparencyClientOptions? options = null; - - Assert.Throws(() => - options!.ConfigureTransactionNotCachedRetry()); - } - - [Test] - public void ConfigureTransactionNotCachedRetry_DefaultParams_ReturnsSameInstance() - { - var options = new CodeTransparencyClientOptions(); - - var result = options.ConfigureTransactionNotCachedRetry(); - - Assert.That(result, Is.SameAs(options)); - } - - [Test] - public void ConfigureTransactionNotCachedRetry_CustomParams_ReturnsSameInstance() - { - var options = new CodeTransparencyClientOptions(); - - var result = options.ConfigureTransactionNotCachedRetry( - retryDelay: TimeSpan.FromMilliseconds(100), - maxRetries: 16); - - Assert.That(result, Is.SameAs(options)); - } - - #endregion - - #region Test Helpers - - /// - /// 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) - { - return HttpPipelineBuilder.Build( - new TestClientOptions(transport, policy)); - } - - /// - /// Creates an HttpMessage with the given method and URI, ready to send through the pipeline. - /// - private static HttpMessage CreateHttpMessage(HttpPipeline pipeline, RequestMethod method, string uri) - { - var message = pipeline.CreateMessage(); - message.Request.Method = method; - message.Request.Uri.Reset(new Uri(uri)); - return message; - } - - /// - /// Creates a mock 503 response with a CBOR problem-details body containing TransactionNotCached. - /// - private static MockResponse CreateTransactionNotCachedResponse() - { - var response = new MockResponse(503); - response.AddHeader("Retry-After", "1"); - response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); - return response; - } - - /// - /// Creates CBOR problem details bytes with the error code in the Detail field (key -4). - /// - 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 CBOR problem details bytes with the error code in the Title field (key -2). - /// - private static byte[] CreateCborProblemDetailsBytesInTitle(string titleValue) - { - var writer = new CborWriter(); - writer.WriteStartMap(2); - writer.WriteInt32(-3); // status - writer.WriteInt32(503); - writer.WriteInt32(-2); // title - writer.WriteTextString(titleValue); - writer.WriteEndMap(); - return writer.Encode(); - } - - /// - /// Minimal ClientOptions subclass for building a pipeline with our custom policy and a mock transport. - /// Disables the SDK's default retry to isolate the policy's behavior. - /// - private sealed class TestClientOptions : ClientOptions - { - public TestClientOptions(MockTransport transport, MstTransactionNotCachedPolicy policy) - { - Transport = transport; - Retry.MaxRetries = 0; // Disable SDK retries to test policy in isolation - AddPolicy(policy, HttpPipelinePosition.PerRetry); - } - } - - #endregion -} diff --git a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs index f97094c5..6b822c70 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs @@ -15,9 +15,10 @@ 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-related headers + /// (Retry-After, retry-after-ms, x-ms-retry-after-ms) for improved + /// MST client performance. /// /// The to configure. /// @@ -32,24 +33,25 @@ 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 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. /// /// /// /// 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 +61,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.PerRetry); return options; diff --git a/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs new file mode 100644 index 00000000..2c485fc7 --- /dev/null +++ b/CoseSign1.Transparent.MST/MstPerformanceOptimizationPolicy.cs @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +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 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 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 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. +/// +/// +/// +/// Pipeline position: Register this policy in the +/// 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.PerRetry); +/// 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; + + /// + /// 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. + /// + 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; + + /// + /// 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); + } + + // Emit a diagnostic activity for every response so we can confirm in production + // that the policy is being invoked and what it sees. + string? requestUri = message.Request.Uri?.ToUri()?.AbsoluteUri; + int responseStatus = message.Response?.Status ?? 0; + using Activity? evalActivity = PolicyActivitySource.StartActivity( + "MstPerformanceOptimization.Evaluate", + ActivityKind.Internal); + evalActivity?.SetTag("http.url", requestUri); + evalActivity?.SetTag("http.request.method", message.Request.Method.ToString()); + evalActivity?.SetTag("http.response.status_code", responseStatus); + evalActivity?.SetTag("mst.policy.is_entries", IsEntriesResponse(message)); + evalActivity?.SetTag("mst.policy.is_operations", IsOperationsResponse(message)); + evalActivity?.SetTag("mst.policy.is_503_entries_get", IsEntriesServiceUnavailableResponse(message)); + + // Strip Retry-After from operations responses (LRO polling) so the SDK uses our configured interval. + if (IsOperationsResponse(message)) + { + evalActivity?.SetTag("mst.policy.action", "strip_operations_headers"); + StripRetryAfterHeader(message); + return; + } + + // Only process 503 on /entries/ - other responses pass through unchanged. + if (!IsEntriesServiceUnavailableResponse(message)) + { + evalActivity?.SetTag("mst.policy.action", "passthrough"); + return; + } + + // 503 on /entries/ - perform fast retries with tracing. + evalActivity?.SetTag("mst.policy.action", "accelerated_retry"); + using Activity? retryActivity = PolicyActivitySource.StartActivity( + "MstPerformanceOptimization.AcceleratedRetry", + ActivityKind.Internal); + + retryActivity?.SetTag("mst.policy.initial_status", 503); + retryActivity?.SetTag("mst.policy.max_retries", _maxRetries); + retryActivity?.SetTag("mst.policy.retry_delay_ms", _retryDelay.TotalMilliseconds); + retryActivity?.SetTag("http.url", requestUri); + + // 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++) + { + using Activity? attemptActivity = PolicyActivitySource.StartActivity( + "MstPerformanceOptimization.RetryAttempt", + ActivityKind.Internal); + attemptActivity?.SetTag("mst.policy.attempt", attempt + 1); + + 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); + } + + int attemptStatus = message.Response?.Status ?? 0; + attemptActivity?.SetTag("http.response.status_code", attemptStatus); + + if (!IsEntriesServiceUnavailableResponse(message)) + { + // Success or different error - strip Retry-After and return. + attemptActivity?.SetTag("mst.policy.result", "resolved"); + retryActivity?.SetTag("mst.policy.resolved_at_attempt", attempt + 1); + retryActivity?.SetTag("mst.policy.final_status", attemptStatus); + StripRetryAfterHeader(message); + return; + } + + attemptActivity?.SetTag("mst.policy.result", "still_503"); + } + + // All fast retries exhausted — strip Retry-After before returning the final 503. + // This prevents the SDK's RetryPolicy from waiting the server-specified delay. + retryActivity?.SetTag("mst.policy.resolved_at_attempt", 0); + retryActivity?.SetTag("mst.policy.final_status", 503); + retryActivity?.SetTag("mst.policy.result", "exhausted"); + StripRetryAfterHeader(message); + } + + /// + /// 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) + { + return; + } + + // Check if any retry-related header is present + bool hasRetryHeader = RetryAfterHeaders.Any(header => response.Headers.Contains(header)); + + if (!hasRetryHeader) + { + return; + } + + // Wrap the response in a HeaderFilteringResponse that excludes all retry headers. + message.Response = new HeaderFilteringResponse(response, RetryAfterHeaders); + } + + /// + /// 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() + { + return _inner.Headers.Where(header => !_excludedHeaders.Contains(header.Name)); + } +} diff --git a/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs b/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs deleted file mode 100644 index 56dd35c5..00000000 --- a/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs +++ /dev/null @@ -1,301 +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. 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); -/// 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 new file mode 100644 index 00000000..e00eb2e4 --- /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.ConfigureMstPerformanceOptimizations( + 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* diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index 01560104..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,27 +311,77 @@ catch (InvalidOperationException ex) } ``` -### TransactionNotCached Fast Retry 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 (`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. +#### ⚡ Best Practice: Configure Both Optimizations Together -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. +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.ConfigureTransactionNotCachedRetry(); // 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(); -options.ConfigureTransactionNotCachedRetry( +options.ConfigureMstPerformanceOptimizations( retryDelay: TimeSpan.FromMilliseconds(100), // faster polling maxRetries: 16); // longer window ``` @@ -333,20 +392,50 @@ using Azure.Core.Pipeline; var options = new CodeTransparencyClientOptions(); options.AddPolicy( - new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(200), 10), + new MstPerformanceOptimizationPolicy(TimeSpan.FromMilliseconds(200), 10), HttpPipelinePosition.PerRetry); ``` -> **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.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. + +#### 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 {