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
{