From 100e7e2ace1332314fa5ac7e0d37ed32f50ddd15 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:42:55 +0300 Subject: [PATCH 01/17] feat: add pipeline stage, policy base, and runner Introduces PipelineStage (sparse enum with pillar/non-pillar semantics), HttpPipelinePolicy (abstract base; async-only in v1), and PipelineRunner (immutable readonly struct that advances the policy chain and terminates at the transport). Tests verify stage-order execution and re-entrancy. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/HttpPipelinePolicy.cs | 46 ++++++++ .../Pipeline/PipelineRunner.cs | 63 +++++++++++ .../Pipeline/PipelineStage.cs | 70 ++++++++++++ .../Pipeline/PipelineRunnerTests.cs | 106 ++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs new file mode 100644 index 0000000..695c184 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Base class for every policy in the HTTP pipeline. +/// +/// +/// +/// A policy participates in the request/response lifecycle by implementing +/// . Before calling next.RunAsync, a policy may mutate +/// ; after the call returns, it may inspect or replace +/// . +/// +/// +/// Re-entrancy. is a value type, so a policy may call +/// next.RunAsync more than once — this is how redirect and retry policies work. +/// +/// +/// Async-only in v1. There is no synchronous Process override on this base class. +/// The sync entry point on the pipeline drives the async chain via a blocking +/// bridge; concrete policy subclasses are only required to implement the async path. +/// +/// +public abstract class HttpPipelinePolicy +{ + /// + /// The stage at which this policy is inserted in the pipeline. + /// + public abstract PipelineStage Stage { get; } + + /// + /// Asynchronously participates in processing the request/response. + /// + /// + /// The mutable context carrying the current , + /// , and ancillary state for this call. + /// + /// + /// The continuation that runs the remaining policies and eventually invokes the transport. + /// Call this to forward the request; omit the call to short-circuit the chain. + /// + /// A that completes when the policy has finished. + public abstract ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation); +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs new file mode 100644 index 0000000..72d61d3 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// The "next" continuation passed to each call. +/// Advances the policy index and ultimately invokes the transport. +/// +/// +/// +/// is a readonly struct so it carries zero allocation per +/// policy hop. A policy may call more than once (e.g. retry, redirect) +/// because the runner is immutable — each call re-advances from the same index with its own +/// in-flight state. +/// +/// +/// Callers must not retain or share a beyond the duration of +/// . +/// +/// +public readonly struct PipelineRunner +{ + private readonly HttpPipelinePolicy[] _policies; + private readonly int _index; + private readonly IAsyncHttpClient _transport; + + /// + /// Initializes a runner. Called by the pipeline entry point and recursively by + /// . + /// + /// The ordered (sorted-by-stage) policy array. + /// The index of the next policy to invoke. + /// The terminal transport invoked when all policies have run. + internal PipelineRunner(HttpPipelinePolicy[] policies, int index, IAsyncHttpClient transport) + { + _policies = policies; + _index = index; + _transport = transport; + } + + /// + /// Runs the remainder of the pipeline starting at the current index, then invokes the + /// transport if no earlier policy short-circuited. + /// + /// The mutable context for the current call. + /// A that completes when the pipeline tail has run. + public async ValueTask RunAsync(PipelineContext context) + { + if (_index >= _policies.Length) + { + context.Response = await _transport + .ExecuteAsync(context.Request, context.CancellationToken) + .ConfigureAwait(false); + return; + } + + var next = new PipelineRunner(_policies, _index + 1, _transport); + await _policies[_index].ProcessAsync(context, next).ConfigureAwait(false); + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs new file mode 100644 index 0000000..8f0ccef --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs @@ -0,0 +1,70 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Identifies where in the pipeline chain a policy is inserted. +/// Policies execute in ascending numeric order (outermost first on the way in; +/// innermost first on the way out). +/// +/// +/// +/// Numbers are sparse to leave room for future stages without breaking existing values. +/// +/// +/// Pillar stages, , , +/// , and — admit exactly one policy each. +/// Adding a second policy to a pillar stage is a configuration error detected at +/// pipeline build time. +/// +/// +/// Non-pillar stages and — may hold +/// multiple policies, which execute in the order they were registered. +/// +/// +public enum PipelineStage +{ + /// + /// Outermost stage. Runs once per logical operation — opens the operation span and applies + /// the overall deadline. Pillar: at most one policy. + /// + Operation = 100, + + /// + /// Redirect-following stage. Runs outside the retry loop so each hop triggers a full retry + /// sequence. Pillar: at most one policy. + /// + Redirect = 200, + + /// + /// Per-call stage (non-pillar). Policies here run once per logical call, above the retry + /// boundary — suitable for stable cross-attempt concerns such as idempotency keys and + /// client identity headers. + /// + PerCall = 250, + + /// + /// Retry stage. Wraps everything below it so that each retry attempt re-executes all + /// inner stages. Pillar: at most one policy. + /// + Retry = 300, + + /// + /// Per-attempt stage (non-pillar). Policies here run on every attempt inside the retry + /// loop — suitable for per-attempt concerns such as a fresh Date header. + /// + PerAttempt = 400, + + /// + /// Auth stage. Placed inside the retry loop so a token refresh applies to the next + /// retry attempt. Pillar: at most one policy. + /// + Auth = 500, + + /// + /// Diagnostics stage. Closest to the transport wire; wraps the per-attempt span, + /// metrics, and structured log events. Pillar: at most one policy. + /// + Diagnostics = 600, +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs new file mode 100644 index 0000000..59e50ca --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class PipelineRunnerTests +{ + private static Request MakeRequest() => + Request.Get("https://api.example.com/v1/resource"); + + private static PipelineContext MakeContext() => + new(MakeRequest(), new DexpaceClientOptions()); + + // --------------------------------------------------------------------------- + // Test fakes + // --------------------------------------------------------------------------- + + private sealed class RecordingPolicy(string name, PipelineStage stage, List log) + : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add($"{name}:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add($"{name}:out"); + } + } + + private sealed class FakeTransport : IAsyncHttpClient + { + private readonly Response _canned; + + public FakeTransport(Response? canned = null) => + _canned = canned ?? new Response(Status.Ok); + + public int InvocationCount { get; private set; } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + InvocationCount++; + return Task.FromResult(_canned); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + [Fact] + public async Task ExecutionOrder_PoliciesRunInStageOrderInAndReversedOut_TransportInvokedOnce() + { + var log = new List(); + var transport = new FakeTransport(); + + // a = Operation (100), b = PerAttempt (400) — stage ordering: a before b + var policies = new HttpPipelinePolicy[] + { + new RecordingPolicy("a", PipelineStage.Operation, log), + new RecordingPolicy("b", PipelineStage.PerAttempt, log), + }; + + var runner = new PipelineRunner(policies, 0, transport); + var context = MakeContext(); + await runner.RunAsync(context); + + Assert.Equal(["a:in", "b:in", "b:out", "a:out"], log); + Assert.Equal(1, transport.InvocationCount); + } + + [Fact] + public async Task Reentrancy_PolicyCallingNextTwice_TransportInvokedTwice() + { + var transport = new FakeTransport(); + var doubleCallPolicy = new DoubleDipPolicy(); + var policies = new HttpPipelinePolicy[] { doubleCallPolicy }; + + var runner = new PipelineRunner(policies, 0, transport); + var context = MakeContext(); + await runner.RunAsync(context); + + Assert.Equal(2, transport.InvocationCount); + } + + private sealed class DoubleDipPolicy : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + await continuation.RunAsync(context).ConfigureAwait(false); + await continuation.RunAsync(context).ConfigureAwait(false); + } + } +} From 582a4914302857fb937a90674b49f03ab5cbc355 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:45:51 +0300 Subject: [PATCH 02/17] feat: add PipelineBuilder Introduces PipelineBuilder with fluent Add/InsertBefore/InsertAfter/ Replace/Remove operations. Build stable-sorts by stage, validates pillar cardinality (throws InvalidOperationException naming the stage when violated), and produces an HttpPipeline. Also adds PipelineStageHelper (internal IsPillar/PillarStages) and HttpPipeline (entry point with async SendAsync and blocking Send bridge). Co-Authored-By: Claude Opus 4.8 --- src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs | 84 +++++++ .../Pipeline/PipelineBuilder.cs | 161 +++++++++++++ .../Pipeline/PipelineStageHelper.cs | 37 +++ .../Pipeline/PipelineBuilderTests.cs | 212 ++++++++++++++++++ 4 files changed, 494 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs b/src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs new file mode 100644 index 0000000..9f4fe56 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// The entry point for sending an HTTP request through the configured policy chain. +/// +/// +/// +/// Instances are created exclusively by . The pipeline is +/// immutable after construction: the sorted policy array and transport are captured at build time. +/// +/// +/// Sync bridge. blocks the calling thread by driving the async chain +/// synchronously via GetAwaiter().GetResult(). Callers on a thread pool should prefer +/// to avoid thread starvation. +/// +/// +public sealed class HttpPipeline +{ + private readonly HttpPipelinePolicy[] _policies; + private readonly IAsyncHttpClient _transport; + + internal HttpPipeline(HttpPipelinePolicy[] policies, IAsyncHttpClient transport) + { + _policies = policies; + _transport = transport; + } + + /// + /// Asynchronously sends through the pipeline and returns the + /// response produced by the terminal transport. + /// + /// The request to send. + /// Client options that apply to this call. + /// An optional token to cancel the call. + /// + /// A that completes with the once + /// the pipeline chain has finished. + /// + /// + /// No policy or the transport produced a by the time the chain + /// completed (i.e. the pipeline was short-circuited without setting a response). + /// + public async ValueTask SendAsync( + Request request, + DexpaceClientOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + + var context = new PipelineContext(request, options, cancellationToken); + await new PipelineRunner(_policies, 0, _transport).RunAsync(context).ConfigureAwait(false); + + return context.Response + ?? throw new PipelineAbortedException( + "The pipeline completed without producing a response."); + } + + /// + /// Synchronously sends through the pipeline and returns the + /// response. Blocks the calling thread until the async chain completes. + /// + /// The request to send. + /// Client options that apply to this call. + /// An optional token to cancel the call. + /// The produced by the pipeline. + /// + /// The pipeline completed without producing a response. + /// + public Response Send( + Request request, + DexpaceClientOptions options, + CancellationToken cancellationToken = default) => + SendAsync(request, options, cancellationToken).AsTask().GetAwaiter().GetResult(); +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs new file mode 100644 index 0000000..5df44ab --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs @@ -0,0 +1,161 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Builds an from an ordered set of +/// instances and a terminal transport. +/// +/// +/// +/// Policies are kept in an internal list. appends to that list; +/// InsertBefore<T>, InsertAfter<T>, Replace<T>, and +/// Remove<T> operate relative to the first policy of runtime type T. +/// +/// +/// performs a stable sort by +/// (preserving list order within a stage) and then validates pillar-stage cardinality: +/// stages marked as pillar admit exactly one policy. A violation throws +/// with an actionable message naming the offending stage. +/// +/// +public sealed class PipelineBuilder +{ + private readonly List _list = []; + + /// + /// Appends to the internal list. The stage-based sort happens at + /// time, not here. + /// + /// The policy to add. + /// This builder (fluent interface). + public PipelineBuilder Add(HttpPipelinePolicy policy) + { + ArgumentNullException.ThrowIfNull(policy); + _list.Add(policy); + return this; + } + + /// + /// Inserts immediately before the first policy of runtime type + /// in the current list. + /// + /// The type to search for. + /// The policy to insert. + /// This builder (fluent interface). + /// + /// No policy of type is present in the list. + /// + public PipelineBuilder InsertBefore(HttpPipelinePolicy policy) + where T : HttpPipelinePolicy + { + ArgumentNullException.ThrowIfNull(policy); + var index = FindFirst(); + _list.Insert(index, policy); + return this; + } + + /// + /// Inserts immediately after the first policy of runtime type + /// in the current list. + /// + /// The type to search for. + /// The policy to insert. + /// This builder (fluent interface). + /// + /// No policy of type is present in the list. + /// + public PipelineBuilder InsertAfter(HttpPipelinePolicy policy) + where T : HttpPipelinePolicy + { + ArgumentNullException.ThrowIfNull(policy); + var index = FindFirst(); + _list.Insert(index + 1, policy); + return this; + } + + /// + /// Replaces the first policy of runtime type with + /// . + /// + /// The type to replace. + /// The replacement policy. + /// This builder (fluent interface). + /// + /// No policy of type is present in the list. + /// + public PipelineBuilder Replace(HttpPipelinePolicy policy) + where T : HttpPipelinePolicy + { + ArgumentNullException.ThrowIfNull(policy); + var index = FindFirst(); + _list[index] = policy; + return this; + } + + /// + /// Removes every policy of runtime type from the list. + /// If none are present, this is a no-op. + /// + /// The type to remove. + /// This builder (fluent interface). + public PipelineBuilder Remove() + where T : HttpPipelinePolicy + { + _list.RemoveAll(p => p is T); + return this; + } + + /// + /// Stable-sorts the registered policies by , validates + /// pillar-stage cardinality, and constructs the with the given + /// as the terminal. + /// + /// + /// The terminal transport; invoked after all policies have run. + /// + /// A fully configured . + /// + /// A pillar stage contains more than one policy. + /// + public HttpPipeline Build(IAsyncHttpClient transport) + { + ArgumentNullException.ThrowIfNull(transport); + + // Stable sort by Stage value + HttpPipelinePolicy[] sorted = [.. _list.OrderBy(p => (int)p.Stage)]; + + // Validate pillar cardinality + foreach (var stage in PipelineStageHelper.PillarStages) + { + var count = sorted.Count(p => p.Stage == stage); + if (count > 1) + { + throw new InvalidOperationException( + $"Pipeline stage '{stage}' is a pillar stage and may contain at most one policy, " + + $"but {count} policies were registered for it. " + + $"Remove the duplicate or use a non-pillar stage."); + } + } + + return new HttpPipeline(sorted, transport); + } + + // Returns the index of the first policy of type T, or throws. + private int FindFirst() where T : HttpPipelinePolicy + { + for (var i = 0; i < _list.Count; i++) + { + if (_list[i] is T) + { + return i; + } + } + + throw new InvalidOperationException( + $"No policy of type '{typeof(T).Name}' is registered in this builder."); + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs new file mode 100644 index 0000000..78d3f4d --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Internal helpers for pillar classification. +/// +internal static class PipelineStageHelper +{ + /// + /// Returns when is a pillar stage that + /// admits at most one policy. + /// + internal static bool IsPillar(PipelineStage stage) => stage switch + { + PipelineStage.Operation => true, + PipelineStage.Redirect => true, + PipelineStage.Retry => true, + PipelineStage.Auth => true, + PipelineStage.Diagnostics => true, + _ => false, + }; + + /// + /// The set of all pillar stages, used for cardinality validation during + /// . + /// + internal static readonly PipelineStage[] PillarStages = + [ + PipelineStage.Operation, + PipelineStage.Redirect, + PipelineStage.Retry, + PipelineStage.Auth, + PipelineStage.Diagnostics, + ]; +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs new file mode 100644 index 0000000..7184624 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class PipelineBuilderTests +{ + // --------------------------------------------------------------------------- + // Concrete test policy stubs + // --------------------------------------------------------------------------- + + private abstract class StubPolicy(PipelineStage stage) : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + public override ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) => + continuation.RunAsync(context); + } + + private sealed class OperationStub() : StubPolicy(PipelineStage.Operation); + private sealed class RetryStub() : StubPolicy(PipelineStage.Retry); + private sealed class AuthStub() : StubPolicy(PipelineStage.Auth); + private sealed class DiagnosticsStub() : StubPolicy(PipelineStage.Diagnostics); + private sealed class PerCallStubA() : StubPolicy(PipelineStage.PerCall); + private sealed class PerCallStubB() : StubPolicy(PipelineStage.PerCall); + private sealed class PerAttemptStub() : StubPolicy(PipelineStage.PerAttempt); + + private sealed class FakeTransport : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(new Response(Dexpace.Sdk.Core.Http.Response.Status.Ok)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private static FakeTransport MakeTransport() => new FakeTransport(); + + // --------------------------------------------------------------------------- + // Helper: build and extract the sorted policies from the pipeline via + // a probe policy that records the chain at call time. + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + [Fact] + public void Add_PoliciesAreSortedByStageAfterBuild() + { + // Add in "wrong" order; Build must sort by Stage + var retry = new RetryStub(); + var operation = new OperationStub(); + var perAttempt = new PerAttemptStub(); + + var pipeline = new PipelineBuilder() + .Add(retry) + .Add(operation) + .Add(perAttempt) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + + // Indirect verification: the pipeline must execute without throwing. + // We can't easily inspect internals without reflection; correctness of + // ordering is primarily proven by the PipelineRunnerTests + HttpPipelineTests. + } + + [Fact] + public void Add_MultiplePoliciesInSameNonPillarStage_DoesNotThrow() + { + var a = new PerCallStubA(); + var b = new PerCallStubB(); + + // Should succeed — PerCall is non-pillar + var pipeline = new PipelineBuilder() + .Add(a) + .Add(b) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void Build_TwoPoliciesInPillarStage_Throws() + { + var retry1 = new RetryStub(); + var retry2 = new RetryStub(); + + var ex = Assert.Throws(() => + new PipelineBuilder() + .Add(retry1) + .Add(retry2) + .Build(MakeTransport())); + + Assert.Contains("Retry", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InsertBefore_InsertsBeforeFirstMatchingType() + { + var retry = new RetryStub(); + var auth = new AuthStub(); + var diag = new DiagnosticsStub(); + + // After build, sorted order without InsertBefore would be: retry, auth, diag. + // InsertBefore for a PerCall policy should still resolve by type, + // not by stage order (InsertBefore is position-relative, not stage-relative). + // We test that the call does not throw and produces a valid pipeline. + var perCall = new PerCallStubA(); + + var pipeline = new PipelineBuilder() + .Add(retry) + .Add(auth) + .Add(diag) + .InsertBefore(perCall) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void InsertBefore_TypeNotPresent_Throws() + { + var ex = Assert.Throws(() => + new PipelineBuilder() + .InsertBefore(new PerCallStubA())); + + Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InsertAfter_InsertsAfterFirstMatchingType() + { + var retry = new RetryStub(); + var auth = new AuthStub(); + var perCall = new PerCallStubA(); + + var pipeline = new PipelineBuilder() + .Add(retry) + .Add(auth) + .InsertAfter(perCall) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void InsertAfter_TypeNotPresent_Throws() + { + var ex = Assert.Throws(() => + new PipelineBuilder() + .InsertAfter(new PerCallStubA())); + + Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Replace_SwapsFirstMatchingType() + { + var retry1 = new RetryStub(); + var retry2 = new RetryStub(); + + // Replace the first RetryStub with retry2 — pillar only allows one, so after + // replace there should be exactly one RetryStub and Build must not throw. + var pipeline = new PipelineBuilder() + .Add(retry1) + .Replace(retry2) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void Replace_TypeNotPresent_Throws() + { + var ex = Assert.Throws(() => + new PipelineBuilder() + .Replace(new RetryStub())); + + Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Remove_RemovesAllMatchingTypes() + { + var a = new PerCallStubA(); + var b = new PerCallStubA(); + var retry = new RetryStub(); + + // After Remove, only the retry remains. + var pipeline = new PipelineBuilder() + .Add(a) + .Add(b) + .Add(retry) + .Remove() + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void Build_EmptyPipeline_ProducesValidPipeline() + { + var pipeline = new PipelineBuilder().Build(MakeTransport()); + Assert.NotNull(pipeline); + } +} From 7e9d6ae6ddf25bfdf94bb641d5958b5237026b5f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:46:25 +0300 Subject: [PATCH 03/17] feat: add HttpPipeline entry point with sync bridge Adds HttpPipelineTests covering SendAsync and the blocking Send bridge. The implementation was already landed with PipelineBuilder in the prior commit; this commit captures the Group 3 test coverage. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/HttpPipelineTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs new file mode 100644 index 0000000..ce4da1f --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class HttpPipelineTests +{ + private static Request MakeRequest() => + Request.Get("https://api.example.com/v1/resource"); + + private static DexpaceClientOptions MakeOptions() => new(); + + private sealed class CannedTransport(Response canned) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(canned); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + [Fact] + public async Task SendAsync_ReturnsTransportResponse() + { + var expected = new Response(Status.Ok); + var pipeline = new PipelineBuilder().Build(new CannedTransport(expected)); + + var actual = await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Same(expected, actual); + } + + [Fact] + public void Send_ReturnsTransportResponse() + { + var expected = new Response(Status.Ok); + var pipeline = new PipelineBuilder().Build(new CannedTransport(expected)); + + var actual = pipeline.Send(MakeRequest(), MakeOptions()); + + Assert.Same(expected, actual); + } + + [Fact] + public async Task SendAsync_WithPolicies_PoliciesInvokedAndResponseReturned() + { + var log = new List(); + var expected = new Response(Status.Ok); + + var pipeline = new PipelineBuilder() + .Add(new LoggingPolicy("a", PipelineStage.Operation, log)) + .Add(new LoggingPolicy("b", PipelineStage.PerAttempt, log)) + .Build(new CannedTransport(expected)); + + var actual = await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Same(expected, actual); + Assert.Equal(["a:in", "b:in", "b:out", "a:out"], log); + } + + private sealed class LoggingPolicy(string name, PipelineStage stage, List log) + : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add($"{name}:in"); + await continuation.RunAsync(context); + log.Add($"{name}:out"); + } + } +} From fc9999a9d435fc939018b0e9a65484e7a69016bd Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:55:29 +0300 Subject: [PATCH 04/17] test: verify PipelineBuilder ordering and edits; fix import ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace tautological NotNull-only assertions in PipelineBuilderTests with execution-log tests that exercise the actual pipeline chain. New tests confirm: stage-sort order (Operation → Redirect → Diagnostics) via recording stubs, InsertAfter/InsertBefore within the same stage, Replace swapping one policy for another, and Remove dropping the target while leaving others intact. Also fixes the import ordering that caused dotnet format --verify-no-changes to fail (Http.Response was listed before Http.Request; corrected to alphabetical order and added the missing Configuration using directive required by the new tests). Co-Authored-By: Claude Sonnet 4.6 --- .../Pipeline/PipelineBuilderTests.cs | 261 ++++++++++++------ 1 file changed, 182 insertions(+), 79 deletions(-) diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs index 7184624..a20c88c 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs @@ -2,8 +2,9 @@ // Licensed under the MIT License. See LICENSE in the repository root for details. using Dexpace.Sdk.Core.Client; -using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Configuration; using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; using Xunit; @@ -15,166 +16,254 @@ public class PipelineBuilderTests // Concrete test policy stubs // --------------------------------------------------------------------------- + /// + /// Pass-through stub for cardinality / type-not-found tests. + /// private abstract class StubPolicy(PipelineStage stage) : HttpPipelinePolicy { public override PipelineStage Stage => stage; + public override ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) => continuation.RunAsync(context); } private sealed class OperationStub() : StubPolicy(PipelineStage.Operation); + private sealed class RedirectStub() : StubPolicy(PipelineStage.Redirect); private sealed class RetryStub() : StubPolicy(PipelineStage.Retry); private sealed class AuthStub() : StubPolicy(PipelineStage.Auth); private sealed class DiagnosticsStub() : StubPolicy(PipelineStage.Diagnostics); private sealed class PerCallStubA() : StubPolicy(PipelineStage.PerCall); - private sealed class PerCallStubB() : StubPolicy(PipelineStage.PerCall); private sealed class PerAttemptStub() : StubPolicy(PipelineStage.PerAttempt); + /// + /// Recording stub: appends "name:in" before and "name:out" after the continuation. + /// Used by execution-log tests to verify actual invocation order. + /// + private sealed class RecordingPolicy(string name, PipelineStage stage, List log) + : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add($"{name}:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add($"{name}:out"); + } + } + + /// Two distinct PerCall recording types for InsertAfter/InsertBefore/Replace/Remove tests. + private sealed class RecordingPerCallA(List log) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add("A:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add("A:out"); + } + } + + private sealed class RecordingPerCallA2(List log) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add("A2:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add("A2:out"); + } + } + + private sealed class RecordingPerCallB(List log) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add("B:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add("B:out"); + } + } + + // --------------------------------------------------------------------------- + // Fakes / helpers + // --------------------------------------------------------------------------- + private sealed class FakeTransport : IAsyncHttpClient { public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => - Task.FromResult(new Response(Dexpace.Sdk.Core.Http.Response.Status.Ok)); + Task.FromResult(new Response(Status.Ok)); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } - private static FakeTransport MakeTransport() => new FakeTransport(); + private static FakeTransport MakeTransport() => new(); - // --------------------------------------------------------------------------- - // Helper: build and extract the sorted policies from the pipeline via - // a probe policy that records the chain at call time. - // --------------------------------------------------------------------------- + private static DexpaceClientOptions MakeOptions() => new(); + + private static Request MakeRequest() => Request.Get("https://api.example.com/v1/resource"); // --------------------------------------------------------------------------- - // Tests + // Tests: Stage sort // --------------------------------------------------------------------------- + /// + /// Policies added in reverse stage order must execute in ascending stage order + /// (Operation=100, Redirect=200, Diagnostics=600). + /// [Fact] - public void Add_PoliciesAreSortedByStageAfterBuild() + public async Task Add_StageSortedExecution_PoliciesRunInStageOrder() { - // Add in "wrong" order; Build must sort by Stage - var retry = new RetryStub(); - var operation = new OperationStub(); - var perAttempt = new PerAttemptStub(); + var log = new List(); + // Added in reverse stage order: Diagnostics, Redirect, Operation var pipeline = new PipelineBuilder() - .Add(retry) - .Add(operation) - .Add(perAttempt) + .Add(new RecordingPolicy("diag", PipelineStage.Diagnostics, log)) + .Add(new RecordingPolicy("redirect", PipelineStage.Redirect, log)) + .Add(new RecordingPolicy("op", PipelineStage.Operation, log)) .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); - // Indirect verification: the pipeline must execute without throwing. - // We can't easily inspect internals without reflection; correctness of - // ordering is primarily proven by the PipelineRunnerTests + HttpPipelineTests. + // Build stable-sorts by stage (ascending), so execution order is: + // op (100) → redirect (200) → diag (600), then unwind. + Assert.Equal( + ["op:in", "redirect:in", "diag:in", "diag:out", "redirect:out", "op:out"], + log); } + /// + /// Multiple policies in the same non-pillar stage must not throw at Build time. + /// [Fact] public void Add_MultiplePoliciesInSameNonPillarStage_DoesNotThrow() { - var a = new PerCallStubA(); - var b = new PerCallStubB(); - - // Should succeed — PerCall is non-pillar var pipeline = new PipelineBuilder() - .Add(a) - .Add(b) + .Add(new PerCallStubA()) + .Add(new PerCallStubA()) .Build(MakeTransport()); Assert.NotNull(pipeline); } + /// + /// Two policies in a pillar stage must cause Build to throw with the stage name in the message. + /// [Fact] public void Build_TwoPoliciesInPillarStage_Throws() { - var retry1 = new RetryStub(); - var retry2 = new RetryStub(); - var ex = Assert.Throws(() => new PipelineBuilder() - .Add(retry1) - .Add(retry2) + .Add(new RetryStub()) + .Add(new RetryStub()) .Build(MakeTransport())); Assert.Contains("Retry", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: InsertAfter within a stage + // --------------------------------------------------------------------------- + + /// + /// InsertAfter<A>(B) where A and B share the same stage (PerCall) must produce + /// execution order [A, B] — Build preserves within-stage list order after the sort. + /// [Fact] - public void InsertBefore_InsertsBeforeFirstMatchingType() + public async Task InsertAfter_SameStage_InsertsAfterAnchor() { - var retry = new RetryStub(); - var auth = new AuthStub(); - var diag = new DiagnosticsStub(); - - // After build, sorted order without InsertBefore would be: retry, auth, diag. - // InsertBefore for a PerCall policy should still resolve by type, - // not by stage order (InsertBefore is position-relative, not stage-relative). - // We test that the call does not throw and produces a valid pipeline. - var perCall = new PerCallStubA(); + var log = new List(); var pipeline = new PipelineBuilder() - .Add(retry) - .Add(auth) - .Add(diag) - .InsertBefore(perCall) + .Add(new RecordingPerCallA(log)) + .InsertAfter(new RecordingPerCallB(log)) // list: [A, B] .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Equal(["A:in", "B:in", "B:out", "A:out"], log); } + /// + /// InsertAfter when the anchor type is absent must throw with the type name in the message. + /// [Fact] - public void InsertBefore_TypeNotPresent_Throws() + public void InsertAfter_TypeNotPresent_Throws() { var ex = Assert.Throws(() => new PipelineBuilder() - .InsertBefore(new PerCallStubA())); + .InsertAfter(new PerCallStubA())); Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: InsertBefore within a stage + // --------------------------------------------------------------------------- + + /// + /// InsertBefore<A>(B) where A and B share the same stage (PerCall) must produce + /// execution order [B, A] — Build preserves within-stage list order after the sort. + /// [Fact] - public void InsertAfter_InsertsAfterFirstMatchingType() + public async Task InsertBefore_SameStage_InsertsBeforeAnchor() { - var retry = new RetryStub(); - var auth = new AuthStub(); - var perCall = new PerCallStubA(); + var log = new List(); var pipeline = new PipelineBuilder() - .Add(retry) - .Add(auth) - .InsertAfter(perCall) + .Add(new RecordingPerCallA(log)) + .InsertBefore(new RecordingPerCallB(log)) // list: [B, A] .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Equal(["B:in", "A:in", "A:out", "B:out"], log); } + /// + /// InsertBefore when the anchor type is absent must throw with the type name in the message. + /// [Fact] - public void InsertAfter_TypeNotPresent_Throws() + public void InsertBefore_TypeNotPresent_Throws() { var ex = Assert.Throws(() => new PipelineBuilder() - .InsertAfter(new PerCallStubA())); + .InsertBefore(new PerCallStubA())); Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: Replace + // --------------------------------------------------------------------------- + + /// + /// Replace<A>(A2) where A2 is a distinct PerCall type must put A2 in the execution + /// log and exclude A. + /// [Fact] - public void Replace_SwapsFirstMatchingType() + public async Task Replace_SubstitutesPolicy_LogContainsReplacementNotOriginal() { - var retry1 = new RetryStub(); - var retry2 = new RetryStub(); + var log = new List(); - // Replace the first RetryStub with retry2 — pillar only allows one, so after - // replace there should be exactly one RetryStub and Build must not throw. var pipeline = new PipelineBuilder() - .Add(retry1) - .Replace(retry2) + .Add(new RecordingPerCallA(log)) + .Replace(new RecordingPerCallA2(log)) // A swapped for A2 .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Contains("A2:in", log); + Assert.DoesNotContain("A:in", log); } + /// + /// Replace when the target type is absent must throw with the type name in the message. + /// [Fact] public void Replace_TypeNotPresent_Throws() { @@ -185,28 +274,42 @@ public void Replace_TypeNotPresent_Throws() Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: Remove + // --------------------------------------------------------------------------- + + /// + /// Remove<A> with both A and B present must keep B in the execution log and drop A. + /// [Fact] - public void Remove_RemovesAllMatchingTypes() + public async Task Remove_RemovesMatchingType_OtherPolicyStillRuns() { - var a = new PerCallStubA(); - var b = new PerCallStubA(); - var retry = new RetryStub(); + var log = new List(); - // After Remove, only the retry remains. var pipeline = new PipelineBuilder() - .Add(a) - .Add(b) - .Add(retry) - .Remove() + .Add(new RecordingPerCallA(log)) + .Add(new RecordingPerCallB(log)) + .Remove() .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Contains("B:in", log); + Assert.DoesNotContain("A:in", log); } + // --------------------------------------------------------------------------- + // Tests: Edge cases + // --------------------------------------------------------------------------- + + /// + /// An empty builder must produce a valid pipeline that the transport can service. + /// [Fact] - public void Build_EmptyPipeline_ProducesValidPipeline() + public async Task Build_EmptyPipeline_TransportResponds() { var pipeline = new PipelineBuilder().Build(MakeTransport()); - Assert.NotNull(pipeline); + var response = await pipeline.SendAsync(MakeRequest(), MakeOptions()); + Assert.NotNull(response); } } From 658af25500e031347a56516f4c5be610594fb9cb Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:19:04 +0300 Subject: [PATCH 05/17] feat: add SetDatePolicy Stamps a fresh RFC 1123 Date header on every request attempt (PerAttempt stage), replacing any prior value. Accepts an optional TimeProvider for deterministic testing; defaults to TimeProvider.System. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/SetDatePolicy.cs | 49 ++++++ .../Pipeline/Policies/SetDatePolicyTests.cs | 160 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/SetDatePolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/SetDatePolicyTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/SetDatePolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/SetDatePolicy.cs new file mode 100644 index 0000000..ffec760 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/SetDatePolicy.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// A per-attempt pipeline policy that stamps a fresh RFC 1123 Date header on each +/// outgoing request, replacing any value already present. +/// +/// +/// Placed at , this policy runs inside the retry loop so +/// that every attempt carries the current wall-clock time rather than the time at which the +/// original call was initiated. +/// +public sealed class SetDatePolicy : HttpPipelinePolicy +{ + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new . + /// + /// + /// The time source used to obtain the current UTC instant. Defaults to + /// when . + /// + public SetDatePolicy(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public override PipelineStage Stage => PipelineStage.PerAttempt; + + /// + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + var dateValue = _timeProvider.GetUtcNow().ToString("r"); + context.Request = context.Request with + { + Headers = context.Request.Headers.Set(HttpHeaderName.WellKnown.Date.Original, dateValue) + }; + + await continuation.RunAsync(context).ConfigureAwait(false); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/SetDatePolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/SetDatePolicyTests.cs new file mode 100644 index 0000000..6dcd879 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/SetDatePolicyTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class SetDatePolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Request MakeRequest() => Request.Get("https://api.example.com/v1/items"); + + private static DexpaceClientOptions MakeOptions() => new(); + + /// + /// Captures the last request it received and returns a canned 200 OK. + /// + private sealed class CapturingTransport : IAsyncHttpClient + { + public Request? LastRequest { get; private set; } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + LastRequest = request; + return Task.FromResult(new Response(Status.Ok)); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsPerAttempt() + { + var policy = new SetDatePolicy(); + Assert.Equal(PipelineStage.PerAttempt, policy.Stage); + } + + // ------------------------------------------------------------------------- + // Date header stamping + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_SetsDateHeader_ReplacesPriorValue() + { + // Arrange: fixed time so we can assert the exact value + var fixedUtc = new DateTimeOffset(2026, 6, 14, 12, 0, 0, TimeSpan.Zero); + var provider = new FakeTimeProvider(fixedUtc); + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new SetDatePolicy(provider)) + .Build(transport); + + // Act + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + // Assert: RFC 1123 "r" format + var dateHeader = transport.LastRequest!.Headers.Get("Date"); + Assert.Equal(fixedUtc.ToString("r"), dateHeader); + } + + [Fact] + public async Task ProcessAsync_ReplacesExistingDateHeader() + { + // Arrange: request already has a stale Date header + var fixedUtc = new DateTimeOffset(2026, 6, 14, 9, 0, 0, TimeSpan.Zero); + var provider = new FakeTimeProvider(fixedUtc); + + var staleRequest = MakeRequest() with + { + Headers = Headers.Empty.Set("Date", "Mon, 01 Jan 2024 00:00:00 GMT") + }; + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new SetDatePolicy(provider)) + .Build(transport); + + await pipeline.SendAsync(staleRequest, MakeOptions()); + + // The stale value must be replaced, not appended + var values = transport.LastRequest!.Headers.GetAll("Date"); + Assert.Single(values); + Assert.Equal(fixedUtc.ToString("r"), values[0]); + } + + [Fact] + public async Task ProcessAsync_UsesSystemTimeProvider_WhenNullPassed() + { + // Arrange: no explicit time provider → uses TimeProvider.System + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new SetDatePolicy()) // null → TimeProvider.System + .Build(transport); + + var before = DateTimeOffset.UtcNow; + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + var after = DateTimeOffset.UtcNow; + + var raw = transport.LastRequest!.Headers.Get("Date"); + Assert.NotNull(raw); + var parsed = DateTimeOffset.ParseExact(raw, "r", null); + Assert.InRange(parsed, before.AddSeconds(-1), after.AddSeconds(1)); + } + + [Fact] + public async Task ProcessAsync_StampsNewDatePerAttempt() + { + // Arrange: increment time between two calls + var times = new[] + { + new DateTimeOffset(2026, 6, 14, 10, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 6, 14, 10, 0, 5, TimeSpan.Zero), + }; + var provider = new SequentialTimeProvider(times); + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new SetDatePolicy(provider)) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + var first = transport.LastRequest!.Headers.Get("Date"); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + var second = transport.LastRequest!.Headers.Get("Date"); + + Assert.NotEqual(first, second); + } + + // ------------------------------------------------------------------------- + // Fake TimeProvider helpers + // ------------------------------------------------------------------------- + + private sealed class FakeTimeProvider(DateTimeOffset utc) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utc; + } + + private sealed class SequentialTimeProvider(DateTimeOffset[] times) : TimeProvider + { + private int _index; + + public override DateTimeOffset GetUtcNow() => times[_index++ % times.Length]; + } +} From e2cfc1a0742082e60bd6aa909ba805f6fafa4b3e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:19:46 +0300 Subject: [PATCH 06/17] feat: add ClientIdentityPolicy Stamps the User-Agent header on every outgoing request (PerCall stage), replacing any prior value. Reads the agent string from DexpaceClientOptions.UserAgent so callers can override it without subclassing. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/ClientIdentityPolicy.cs | 36 ++++++ .../Policies/ClientIdentityPolicyTests.cs | 105 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/ClientIdentityPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ClientIdentityPolicyTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/ClientIdentityPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/ClientIdentityPolicy.cs new file mode 100644 index 0000000..6525b1b --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/ClientIdentityPolicy.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// A per-call pipeline policy that stamps the User-Agent header on each outgoing request, +/// replacing any value already present. +/// +/// +/// Placed at , this policy runs once above the retry boundary. +/// The value is taken from so +/// callers can override the default without subclassing. +/// +public sealed class ClientIdentityPolicy : HttpPipelinePolicy +{ + /// + public override PipelineStage Stage => PipelineStage.PerCall; + + /// + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + context.Request = context.Request with + { + Headers = context.Request.Headers.Set( + HttpHeaderName.WellKnown.UserAgent.Original, + context.Options.UserAgent) + }; + + await continuation.RunAsync(context).ConfigureAwait(false); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ClientIdentityPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ClientIdentityPolicyTests.cs new file mode 100644 index 0000000..40f83ab --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ClientIdentityPolicyTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class ClientIdentityPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Request MakeRequest() => Request.Get("https://api.example.com/v1/items"); + + private sealed class CapturingTransport : IAsyncHttpClient + { + public Request? LastRequest { get; private set; } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + LastRequest = request; + return Task.FromResult(new Response(Status.Ok)); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsPerCall() + { + var policy = new ClientIdentityPolicy(); + Assert.Equal(PipelineStage.PerCall, policy.Stage); + } + + // ------------------------------------------------------------------------- + // User-Agent stamping + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_SetsUserAgentFromOptions() + { + var options = new DexpaceClientOptions { UserAgent = "my-client/1.0" }; + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ClientIdentityPolicy()) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), options); + + var ua = transport.LastRequest!.Headers.Get("User-Agent"); + Assert.Equal("my-client/1.0", ua); + } + + [Fact] + public async Task ProcessAsync_ReplacesExistingUserAgentHeader() + { + var options = new DexpaceClientOptions { UserAgent = "override-agent/2.0" }; + + // Request already carries an old User-Agent + var request = MakeRequest() with + { + Headers = Headers.Empty.Set("User-Agent", "old-agent/0.1") + }; + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ClientIdentityPolicy()) + .Build(transport); + + await pipeline.SendAsync(request, options); + + // Must be replaced, not appended + var values = transport.LastRequest!.Headers.GetAll("User-Agent"); + Assert.Single(values); + Assert.Equal("override-agent/2.0", values[0]); + } + + [Fact] + public async Task ProcessAsync_UsesDefaultUserAgent_WhenOptionsIsDefault() + { + var options = new DexpaceClientOptions(); // default UA: dexpace-dotnet/ + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ClientIdentityPolicy()) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), options); + + var ua = transport.LastRequest!.Headers.Get("User-Agent"); + Assert.NotNull(ua); + Assert.StartsWith("dexpace-dotnet/", ua, StringComparison.Ordinal); + } +} From e374fd81fa89df4234e9ead46a629f2a986345a8 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:22:58 +0300 Subject: [PATCH 07/17] feat: add IdempotencyPolicy Stamps an Idempotency-Key header (GUID v4) on outgoing requests for configured HTTP methods (default: POST). The key is generated once per logical call, stashed in the PipelineContext property bag under "dexpace.idempotency-key", and reused on retry/redirect re-runs of the same context. Existing caller-supplied keys are preserved. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/IdempotencyPolicy.cs | 73 ++++++ .../Policies/IdempotencyPolicyTests.cs | 241 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/IdempotencyPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/IdempotencyPolicyTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/IdempotencyPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/IdempotencyPolicy.cs new file mode 100644 index 0000000..213a3c5 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/IdempotencyPolicy.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// A per-call pipeline policy that attaches an Idempotency-Key header to outgoing +/// requests for configured HTTP methods, enabling safe retries on transient failures. +/// +/// +/// +/// By default only POST requests receive the header. The key is a GUID v4 generated +/// once per logical call and stashed in the property bag under +/// the key "dexpace.idempotency-key". Redirect hops and retry attempts that re-enter +/// the policy on the same context reuse the same GUID, satisfying the idempotency contract. +/// +/// +/// If the request already carries an Idempotency-Key header (set by the caller or a +/// previous pass), the policy does not overwrite it. +/// +/// +public sealed class IdempotencyPolicy : HttpPipelinePolicy +{ + /// Context property-bag key under which the generated idempotency key is stored. + internal const string PropertyKey = "dexpace.idempotency-key"; + + private readonly HashSet _methods; + + /// + /// Initializes a new . + /// + /// + /// The HTTP methods that should receive an idempotency key. Defaults to + /// POST when . + /// + public IdempotencyPolicy(IEnumerable? methods = null) + { + _methods = methods is not null + ? new HashSet(methods) + : [Method.Post]; + } + + /// + public override PipelineStage Stage => PipelineStage.PerCall; + + /// + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + if (_methods.Contains(context.Request.Method) + && !context.Request.Headers.Contains(HttpHeaderName.Of("Idempotency-Key").Original)) + { + // Reuse a key that was already generated for this context (e.g. retry re-entering here), + // or generate a fresh one and stash it. + var key = context.GetProperty(PropertyKey); + if (key is null) + { + key = Guid.NewGuid().ToString(); + context.SetProperty(PropertyKey, key); + } + + context.Request = context.Request with + { + Headers = context.Request.Headers.Set("Idempotency-Key", key) + }; + } + + await continuation.RunAsync(context).ConfigureAwait(false); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/IdempotencyPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/IdempotencyPolicyTests.cs new file mode 100644 index 0000000..9b8a964 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/IdempotencyPolicyTests.cs @@ -0,0 +1,241 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class IdempotencyPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static DexpaceClientOptions MakeOptions() => new(); + + /// + /// Captures every request it receives and returns a canned 200 OK. + /// + private sealed class CapturingTransport : IAsyncHttpClient + { + private readonly List _requests = []; + + public List Requests => _requests; + public Request? LastRequest => _requests.Count > 0 ? _requests[^1] : null; + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + _requests.Add(request); + return Task.FromResult(new Response(Status.Ok)); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsPerCall() + { + var policy = new IdempotencyPolicy(); + Assert.Equal(PipelineStage.PerCall, policy.Stage); + } + + // ------------------------------------------------------------------------- + // Key generation — POST (default configured method) + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_Post_SetsIdempotencyKeyHeader() + { + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new IdempotencyPolicy()) + .Build(transport); + + await pipeline.SendAsync(Request.Post("https://api.example.com/v1/items", RequestBody.FromBytes(ReadOnlyMemory.Empty)), MakeOptions()); + + var key = transport.LastRequest!.Headers.Get("Idempotency-Key"); + Assert.NotNull(key); + Assert.True(Guid.TryParse(key, out _), $"Expected a GUID, got: {key}"); + } + + [Fact] + public async Task ProcessAsync_Get_DoesNotSetIdempotencyKeyHeader() + { + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new IdempotencyPolicy()) + .Build(transport); + + await pipeline.SendAsync(Request.Get("https://api.example.com/v1/items"), MakeOptions()); + + var key = transport.LastRequest!.Headers.Get("Idempotency-Key"); + Assert.Null(key); + } + + // ------------------------------------------------------------------------- + // Key is stable when the pipeline re-runs for a retry (two SendAsync calls + // simulate what a retry policy would do: same policy, fresh context each time, + // but we verify the *within-one-context* stability via two executions of a + // "pass-through replay" pipeline below.) + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_SameContextTwice_ReusesKey() + { + // Build a pipeline: IdempotencyPolicy → transport (captures requests). + // We drive the pipeline twice with the same context by calling SendAsync + // twice. Each call constructs a new PipelineContext, so to test re-use we + // instead drive the policy directly through a minimal two-leg pipeline that + // re-runs the policy via the transport capturing both keys. + var transport = new CapturingTransport(); + + // Use a "double-call" transport: on the first call it re-drives the policy + // (simulating a retry) by calling SendAsync again on an inner pipeline, then + // returns a 200. We do this by embedding the policy in a re-entrancy test. + + // Simpler approach: build a pipeline with the policy, call SendAsync twice + // with requests that share NO pre-existing key, and verify the TWO keys are + // DIFFERENT (one fresh context per SendAsync). The intra-context stability + // is covered by the property-bag stashing test below. + var pipeline = new PipelineBuilder() + .Add(new IdempotencyPolicy()) + .Build(transport); + + var postRequest = Request.Post("https://api.example.com/v1/items", RequestBody.FromBytes(ReadOnlyMemory.Empty)); + + await pipeline.SendAsync(postRequest, MakeOptions()); + await pipeline.SendAsync(postRequest, MakeOptions()); + + var key1 = transport.Requests[0].Headers.Get("Idempotency-Key"); + var key2 = transport.Requests[1].Headers.Get("Idempotency-Key"); + + // Two separate calls → two separate keys (each call gets its own context) + Assert.NotNull(key1); + Assert.NotNull(key2); + Assert.NotEqual(key1, key2); + } + + // ------------------------------------------------------------------------- + // Existing header is preserved (caller-supplied key) + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_ExistingIdempotencyKeyNotOverwritten() + { + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new IdempotencyPolicy()) + .Build(transport); + + var requestWithKey = Request.Post( + "https://api.example.com/v1/items", + RequestBody.FromBytes(ReadOnlyMemory.Empty)) + with + { + Headers = Headers.Empty.Set("Idempotency-Key", "caller-supplied-key") + }; + + await pipeline.SendAsync(requestWithKey, MakeOptions()); + + var key = transport.LastRequest!.Headers.Get("Idempotency-Key"); + Assert.Equal("caller-supplied-key", key); + } + + // ------------------------------------------------------------------------- + // Custom method set + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_CustomMethodSet_SetsKeyForConfiguredMethod() + { + var transport = new CapturingTransport(); + // Only PATCH configured, not POST + var pipeline = new PipelineBuilder() + .Add(new IdempotencyPolicy([Method.Patch])) + .Build(transport); + + var patchRequest = Request.Create(Method.Patch, "https://api.example.com/v1/items/1"); + await pipeline.SendAsync(patchRequest, MakeOptions()); + + var key = transport.LastRequest!.Headers.Get("Idempotency-Key"); + Assert.NotNull(key); + Assert.True(Guid.TryParse(key, out _)); + } + + [Fact] + public async Task ProcessAsync_CustomMethodSet_DoesNotSetKeyForUnconfiguredMethod() + { + var transport = new CapturingTransport(); + // Only PATCH configured + var pipeline = new PipelineBuilder() + .Add(new IdempotencyPolicy([Method.Patch])) + .Build(transport); + + // POST is NOT in the custom set + var postRequest = Request.Post("https://api.example.com/v1/items", RequestBody.FromBytes(ReadOnlyMemory.Empty)); + await pipeline.SendAsync(postRequest, MakeOptions()); + + var key = transport.LastRequest!.Headers.Get("Idempotency-Key"); + Assert.Null(key); + } + + // ------------------------------------------------------------------------- + // Key stashed in context property bag and reused on re-run within same context + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_StashesKeyInContextPropertyBag_AndReusesOnRerun() + { + // We verify intra-context key stability by building a pipeline with a + // "double-call" policy that calls continuation.RunAsync twice — simulating + // a retry policy calling the remainder of the chain twice for the same context. + var transport = new CapturingTransport(); + + var pipeline = new PipelineBuilder() + .Add(new DoubleCallPolicy()) // calls continuation twice + .Add(new IdempotencyPolicy()) + .Build(transport); + + var postRequest = Request.Post("https://api.example.com/v1/items", RequestBody.FromBytes(ReadOnlyMemory.Empty)); + await pipeline.SendAsync(postRequest, MakeOptions()); + + // Two requests were captured (double-call sent twice) + Assert.Equal(2, transport.Requests.Count); + + var key1 = transport.Requests[0].Headers.Get("Idempotency-Key"); + var key2 = transport.Requests[1].Headers.Get("Idempotency-Key"); + + // Within the same context the key must be stable + Assert.NotNull(key1); + Assert.Equal(key1, key2); + + // Also verify the property bag was populated + // (tested indirectly via key stability, but we can also confirm via policy contract) + } + + // ------------------------------------------------------------------------- + // Fake: a policy that calls the continuation twice (retry simulation) + // ------------------------------------------------------------------------- + + private sealed class DoubleCallPolicy : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Operation; // outermost + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + await continuation.RunAsync(context).ConfigureAwait(false); + await continuation.RunAsync(context).ConfigureAwait(false); + } + } +} From 845f80e1d81e7003e39c187b1423dd3ec7a187d8 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:25:51 +0300 Subject: [PATCH 08/17] feat: add Response.EnsureSuccessAsync Throws HttpResponseException on non-2xx responses, draining up to 1 MiB of the error body into a buffered ResponseBody so that HttpResponseException.GetErrorAsync can deserialize it without a StreamConsumedException. Headers, Protocol, and ContentType are carried through to the buffered response on the exception. Co-Authored-By: Claude Opus 4.8 --- .../Http/Response/Response.cs | 66 +++++++ .../Http/EnsureSuccessTests.cs | 172 ++++++++++++++++++ .../EnsureSuccessGetErrorRoundTripTests.cs | 77 ++++++++ 3 files changed, 315 insertions(+) create mode 100644 tests/Dexpace.Sdk.Core.Tests/Http/EnsureSuccessTests.cs create mode 100644 tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/EnsureSuccessGetErrorRoundTripTests.cs diff --git a/src/Dexpace.Sdk.Core/Http/Response/Response.cs b/src/Dexpace.Sdk.Core/Http/Response/Response.cs index 2cd339a..5c292dd 100644 --- a/src/Dexpace.Sdk.Core/Http/Response/Response.cs +++ b/src/Dexpace.Sdk.Core/Http/Response/Response.cs @@ -1,6 +1,7 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. +using Dexpace.Sdk.Core.Errors; using Dexpace.Sdk.Core.Http.Common; namespace Dexpace.Sdk.Core.Http.Response; @@ -48,6 +49,71 @@ public Response( /// Shorthand for Status.IsSuccess. public bool IsSuccess => Status.IsSuccess; + /// + /// Throws if the status is not in the 2xx success range. + /// + /// + /// When the status is an error, the response body is drained up to + /// into an in-memory buffer and attached to the + /// thrown exception so that can read + /// it. The cap guards against oversized error pages consuming unbounded memory. + /// + /// A token that can cancel the body-drain operation. + /// A that completes when the check has been performed. + /// + /// The response status is not in the 2xx range. The exception carries a buffered copy of + /// the error body (up to bytes). + /// + public async ValueTask EnsureSuccessAsync(CancellationToken cancellationToken = default) + { + if (IsSuccess) + { + return; + } + + // Drain and cap the body so the caller can read it from the exception. + var rawBytes = await DrainCappedAsync(Body, MaxBufferedErrorBytes, cancellationToken) + .ConfigureAwait(false); + + var bufferedBody = ResponseBody.FromBytes(rawBytes, Body.ContentType); + var bufferedResponse = new Response(Status, Headers, bufferedBody, Protocol); + throw new HttpResponseException(bufferedResponse); + } + + /// + /// The maximum number of bytes buffered from an error response body by + /// . Larger bodies are silently truncated to this limit. + /// + public const int MaxBufferedErrorBytes = 1024 * 1024; // 1 MiB + + private static async Task DrainCappedAsync( + ResponseBody body, + int maxBytes, + CancellationToken cancellationToken) + { + await using var stream = await body.OpenReadAsync(cancellationToken).ConfigureAwait(false); + using var buffer = new MemoryStream(); + + var remaining = maxBytes; + var chunk = new byte[Math.Min(81920, maxBytes)]; + + while (remaining > 0) + { + var toRead = Math.Min(chunk.Length, remaining); + var read = await stream.ReadAsync(chunk.AsMemory(0, toRead), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + buffer.Write(chunk, 0, read); + remaining -= read; + } + + return buffer.ToArray(); + } + /// public void Dispose() { diff --git a/tests/Dexpace.Sdk.Core.Tests/Http/EnsureSuccessTests.cs b/tests/Dexpace.Sdk.Core.Tests/Http/EnsureSuccessTests.cs new file mode 100644 index 0000000..fdb4170 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Http/EnsureSuccessTests.cs @@ -0,0 +1,172 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Response; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Http; + +public sealed class EnsureSuccessTests +{ + // ------------------------------------------------------------------------- + // Success cases — should NOT throw + // ------------------------------------------------------------------------- + + [Theory] + [InlineData(200)] + [InlineData(201)] + [InlineData(204)] + [InlineData(299)] + public async Task EnsureSuccessAsync_SuccessStatusCode_DoesNotThrow(int statusCode) + { + var response = new Response(Status.FromCode(statusCode)); + await response.EnsureSuccessAsync(); // must not throw + } + + // ------------------------------------------------------------------------- + // Error cases — must throw HttpResponseException + // ------------------------------------------------------------------------- + + [Theory] + [InlineData(400)] + [InlineData(401)] + [InlineData(404)] + [InlineData(429)] + [InlineData(500)] + [InlineData(503)] + public async Task EnsureSuccessAsync_ErrorStatusCode_ThrowsHttpResponseException(int statusCode) + { + var response = new Response(Status.FromCode(statusCode)); + await Assert.ThrowsAsync(() => response.EnsureSuccessAsync().AsTask()); + } + + // ------------------------------------------------------------------------- + // Exception carries status + // ------------------------------------------------------------------------- + + [Fact] + public async Task EnsureSuccessAsync_ExceptionCarriesCorrectStatus() + { + var response = new Response(Status.FromCode(404)); + var ex = await Assert.ThrowsAsync(() => response.EnsureSuccessAsync().AsTask()); + Assert.Equal(Status.FromCode(404), ex.Status); + } + + // ------------------------------------------------------------------------- + // Body is buffered in the thrown exception (replayable) + // ------------------------------------------------------------------------- + + [Fact] + public async Task EnsureSuccessAsync_ErrorBodyIsBufferedInException() + { + var bodyBytes = Encoding.UTF8.GetBytes("{\"code\":\"not_found\",\"message\":\"Resource not found\"}"); + var body = ResponseBody.FromBytes(bodyBytes, MediaType.Of("application", "json")); + var response = new Response(Status.FromCode(404), body: body); + + var ex = await Assert.ThrowsAsync(() => response.EnsureSuccessAsync().AsTask()); + + // The body on the exception must be readable (replayable) + var readBytes = await ex.Response.Body.ReadAsBytesAsync(); + Assert.Equal(bodyBytes, readBytes); + } + + [Fact] + public async Task EnsureSuccessAsync_ErrorBodyReadableTwice_AfterBuffering() + { + // Verify that the buffered body on the exception can be opened more than once + // by reading it twice in sequence. + var bodyBytes = Encoding.UTF8.GetBytes("error payload"); + var body = ResponseBody.FromBytes(bodyBytes); + var response = new Response(Status.FromCode(500), body: body); + + var ex = await Assert.ThrowsAsync(() => response.EnsureSuccessAsync().AsTask()); + + var first = await ex.Response.Body.ReadAsBytesAsync(); + + // A second read should also succeed (BytesResponseBody is single-use per contract + // unless we make it replayable; the buffered body IS replayable since + // EnsureSuccessAsync creates a fresh ResponseBody.FromBytes every time — but the + // SAME ResponseBody instance on the exception is single-use after one read. + // The important guarantee: the exception body was successfully buffered from + // the original stream-or-bytes body. We verify the content is correct. + Assert.Equal(bodyBytes, first); + } + + // ------------------------------------------------------------------------- + // Content-type is preserved on the buffered body + // ------------------------------------------------------------------------- + + [Fact] + public async Task EnsureSuccessAsync_PreservesContentTypeOnBufferedBody() + { + var contentType = MediaType.Of("application", "json"); + var body = ResponseBody.FromBytes(Encoding.UTF8.GetBytes("{}"), contentType); + var response = new Response(Status.FromCode(422), body: body); + + var ex = await Assert.ThrowsAsync(() => response.EnsureSuccessAsync().AsTask()); + + Assert.Equal(contentType, ex.Response.Body.ContentType); + } + + // ------------------------------------------------------------------------- + // Status, Headers, and Protocol are carried through + // ------------------------------------------------------------------------- + + [Fact] + public async Task EnsureSuccessAsync_ExceptionResponseCarriesOriginalHeadersAndProtocol() + { + var headers = Headers.Empty.Set("X-Request-Id", "abc-123"); + var response = new Response(Status.FromCode(503), headers: headers, protocol: Protocol.Http2); + + var ex = await Assert.ThrowsAsync(() => response.EnsureSuccessAsync().AsTask()); + + Assert.Equal("abc-123", ex.Response.Headers.Get("X-Request-Id")); + Assert.Equal(Protocol.Http2, ex.Response.Protocol); + } + + // ------------------------------------------------------------------------- + // Cancellation propagates + // ------------------------------------------------------------------------- + + [Fact] + public async Task EnsureSuccessAsync_CancelledToken_ThrowsOperationCanceledException() + { + var body = ResponseBody.FromStream(new NeverEndingStream()); + var response = new Response(Status.FromCode(500), body: body); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => response.EnsureSuccessAsync(cts.Token).AsTask()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /// A stream that never returns data, used to test cancellation. + private sealed class NeverEndingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set => throw new NotSupportedException(); } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/EnsureSuccessGetErrorRoundTripTests.cs b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/EnsureSuccessGetErrorRoundTripTests.cs new file mode 100644 index 0000000..451867f --- /dev/null +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/EnsureSuccessGetErrorRoundTripTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Response; +using Xunit; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests; + +/// +/// End-to-end tests that chain Response.EnsureSuccessAsync with +/// HttpResponseException.GetErrorAsync<T> to verify the buffered body is +/// deserializable after the exception is thrown. +/// +public sealed class EnsureSuccessGetErrorRoundTripTests +{ + private static SystemTextJsonSerde Serde() => new(TestJsonContext.Default); + + [Fact] + public async Task EnsureSuccessAsync_ThenGetErrorAsync_DeserializesBufferedBody() + { + // Arrange: a 422 response carrying a JSON error body + var json = Encoding.UTF8.GetBytes("""{"Code":"validation_failed","Message":"Name is required"}"""); + var body = ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJsonUtf8); + var response = new Response(Status.FromCode(422), body: body); + + // Act: EnsureSuccessAsync should throw with the body buffered + var ex = await Assert.ThrowsAsync( + () => response.EnsureSuccessAsync().AsTask()); + + // Assert: GetErrorAsync can deserialize the body that was buffered by EnsureSuccessAsync + var error = await ex.GetErrorAsync(Serde()); + Assert.Equal(new ApiError("validation_failed", "Name is required"), error); + } + + [Fact] + public async Task EnsureSuccessAsync_ThenGetErrorAsync_CanBeCalledOnceOnStreamBody() + { + // Arrange: a response whose body is backed by a single-use stream (not pre-buffered) + var json = Encoding.UTF8.GetBytes("""{"Code":"server_error","Message":"Unexpected error"}"""); + using var stream = new MemoryStream(json); + var body = ResponseBody.FromStream(stream, CommonMediaTypes.ApplicationJsonUtf8, json.Length); + var response = new Response(Status.FromCode(500), body: body); + + // Act + var ex = await Assert.ThrowsAsync( + () => response.EnsureSuccessAsync().AsTask()); + + // Assert: the stream was drained and buffered; GetErrorAsync can read it + var error = await ex.GetErrorAsync(Serde()); + Assert.Equal(new ApiError("server_error", "Unexpected error"), error); + } + + [Fact] + public async Task EnsureSuccessAsync_WithEmptyBody_ThrowsWithNoContent() + { + // Arrange: error response with an empty body + var response = new Response(Status.FromCode(404)); + + // Act + var ex = await Assert.ThrowsAsync( + () => response.EnsureSuccessAsync().AsTask()); + + // Assert: empty body is readable (zero bytes) + var bytes = await ex.Response.Body.ReadAsBytesAsync(); + Assert.Empty(bytes); + } + + [Fact] + public async Task EnsureSuccessAsync_SuccessResponse_ReturnsWithoutThrowing() + { + var response = new Response(Status.FromCode(200)); + await response.EnsureSuccessAsync(); // must not throw + } +} From c91e039f4f7330258c70315e6110935efd198b60 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:36:48 +0300 Subject: [PATCH 09/17] feat: add OperationPolicy with overall-timeout Add OperationPolicy (PipelineStage.Operation) which applies the overall per-call deadline from DexpaceClientOptions.OverallTimeout. When a positive TimeSpan is configured, a linked CancellationTokenSource is created, armed with CancelAfter, and PipelineContext.CancellationToken is swapped in so every policy and the transport downstream observe the deadline. Non-positive or null values are a no-op (no CTS allocated, no latency cost). Also adds an internal setter to PipelineContext.CancellationToken so the policy can replace the token without reflection. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/PipelineContext.cs | 6 +- .../Pipeline/Policies/OperationPolicy.cs | 53 +++++++ .../Pipeline/Policies/OperationPolicyTests.cs | 132 ++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/OperationPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/OperationPolicyTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs index 0c2e496..cf195b3 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs @@ -66,9 +66,11 @@ public PipelineContext( public DexpaceClientOptions Options { get; } /// - /// A token that can cancel the in-flight operation. + /// A token that can cancel the in-flight operation. The operation policy may replace this + /// with a timeout-linked token before forwarding the call so the overall deadline is + /// enforced throughout the chain. /// - public CancellationToken CancellationToken { get; } + public CancellationToken CancellationToken { get; internal set; } /// /// The zero-based retry attempt counter. 0 on the initial send; diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/OperationPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/OperationPolicy.cs new file mode 100644 index 0000000..af43edb --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/OperationPolicy.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// The outermost pipeline policy. Applies the overall-operation timeout configured in +/// by linking a +/// to the caller's token before forwarding the call. +/// +/// +/// +/// When is a positive +/// , a new is created, the timeout +/// is armed, and is replaced with the linked +/// token so that all policies further down the chain — including the transport — observe the +/// deadline. The CTS is disposed after the call completes (or faults/cancels). +/// +/// +/// Cancellation is not caught. If the deadline fires, +/// propagates to the caller unchanged. The policy does not distinguish between caller-initiated +/// cancellation and deadline expiry — both surface as . +/// +/// +/// When no timeout is configured (or the value is non-positive) the policy is transparent: +/// it simply awaits the continuation without allocating a CTS. +/// +/// +public sealed class OperationPolicy : HttpPipelinePolicy +{ + /// + public override PipelineStage Stage => PipelineStage.Operation; + + /// + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + var timeout = context.Options.OverallTimeout; + + if (timeout is { } ts && ts > TimeSpan.Zero) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken); + cts.CancelAfter(ts); + context.CancellationToken = cts.Token; + await continuation.RunAsync(context).ConfigureAwait(false); + } + else + { + await continuation.RunAsync(context).ConfigureAwait(false); + } + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/OperationPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/OperationPolicyTests.cs new file mode 100644 index 0000000..dfb55d2 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/OperationPolicyTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class OperationPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Request MakeRequest() => Request.Get("https://api.example.com/v1/items"); + + private static DexpaceClientOptions OptionsWithTimeout(TimeSpan timeout) => + new() { OverallTimeout = timeout }; + + private static DexpaceClientOptions OptionsNoTimeout() => new() { OverallTimeout = null }; + + // A transport that delays indefinitely until its token is cancelled. + private sealed class HangingTransport : IAsyncHttpClient + { + public async Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + return new Response(Status.Ok); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // A transport that completes immediately with 200 OK. + private sealed class InstantTransport : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(new Response(Status.Ok)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsOperation() + { + var policy = new OperationPolicy(); + Assert.Equal(PipelineStage.Operation, policy.Stage); + } + + // ------------------------------------------------------------------------- + // Timeout behaviour + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_WithShortTimeout_ThrowsWhenTransportHangs() + { + var pipeline = new PipelineBuilder() + .Add(new OperationPolicy()) + .Build(new HangingTransport()); + + var options = OptionsWithTimeout(TimeSpan.FromMilliseconds(30)); + + await Assert.ThrowsAnyAsync( + () => pipeline.SendAsync(MakeRequest(), options).AsTask()); + } + + [Fact] + public async Task ProcessAsync_WithNoTimeout_CompletesNormally() + { + var pipeline = new PipelineBuilder() + .Add(new OperationPolicy()) + .Build(new InstantTransport()); + + var response = await pipeline.SendAsync(MakeRequest(), OptionsNoTimeout()); + + Assert.Equal(Status.Ok, response.Status); + } + + [Fact] + public async Task ProcessAsync_WithZeroTimeout_CompletesNormally() + { + // A zero TimeSpan is non-positive — treated as "no timeout". + var pipeline = new PipelineBuilder() + .Add(new OperationPolicy()) + .Build(new InstantTransport()); + + var options = OptionsWithTimeout(TimeSpan.Zero); + var response = await pipeline.SendAsync(MakeRequest(), options); + + Assert.Equal(Status.Ok, response.Status); + } + + [Fact] + public async Task ProcessAsync_WithNegativeTimeout_CompletesNormally() + { + // A negative TimeSpan is non-positive — treated as "no timeout". + var pipeline = new PipelineBuilder() + .Add(new OperationPolicy()) + .Build(new InstantTransport()); + + var options = OptionsWithTimeout(TimeSpan.FromSeconds(-1)); + var response = await pipeline.SendAsync(MakeRequest(), options); + + Assert.Equal(Status.Ok, response.Status); + } + + [Fact] + public async Task ProcessAsync_CallerCancellation_Propagates_EvenWithTimeout() + { + // Caller cancels before the pipeline finishes — OperationCanceledException propagates. + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var pipeline = new PipelineBuilder() + .Add(new OperationPolicy()) + .Build(new HangingTransport()); + + var options = OptionsWithTimeout(TimeSpan.FromSeconds(30)); + + await Assert.ThrowsAnyAsync( + () => pipeline.SendAsync(MakeRequest(), options, cts.Token).AsTask()); + } +} From c853ced1f84470412e095b55815c03200bccd303 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:42:24 +0300 Subject: [PATCH 10/17] feat: add RetryPolicy with backoff, jitter, and Retry-After Add RetryPolicy (PipelineStage.Retry) with full-jitter exponential backoff and Retry-After header support. Retries on status codes 408, 429, 500, 502, 503, 504 and on ServiceRequestException / ServiceResponseException. Non- idempotent methods are only retried when the body is replayable and RetryNonIdempotentWhenReplayable is enabled. OperationCanceledException is explicitly excluded from the retry predicate and always propagates. Retry-After is parsed as delta-seconds or RFC 1123 HTTP-date; the HTTP-date path uses the injected TimeProvider for testable "now" lookups. Delays use Task.Delay(TimeSpan, TimeProvider, CancellationToken) so test suites can substitute an InstantTimeProvider that fires timers after 1 ms. The overflow-safe exponent cap (shift <= 30) prevents TimeSpan overflow at high attempt counts. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/RetryPolicy.cs | 229 ++++++++ .../Pipeline/Policies/RetryPolicyTests.cs | 487 ++++++++++++++++++ 2 files changed, 716 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs new file mode 100644 index 0000000..5d5afa9 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs @@ -0,0 +1,229 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// A retry pipeline policy that retries failed requests with exponential back-off and +/// full jitter, optionally honoring Retry-After response headers. +/// +/// +/// +/// Retryable statuses: 408, 429, 500, 502, 503, 504. Any other status (including 4xx) +/// is returned immediately. +/// +/// +/// Retryable exceptions: (request never sent) and +/// (sent but response unreadable). All other exceptions, +/// including , propagate unchanged. +/// +/// +/// Non-idempotent requests are retried only when the request body is replayable +/// (or absent) AND is +/// . +/// +/// +/// Delay: when Retry-After is present and +/// is , the parsed value +/// is used; otherwise the delay is drawn from a uniform random distribution over +/// [0, min(BaseDelay × 2^attempt, MaxDelay)] (full jitter). The +/// passed to the constructor drives both the current-time lookup +/// (for HTTP-date parsing) and the +/// overload so tests can control delays without real sleeps. +/// +/// +/// Response disposal: when a retryable response is going to be retried, the response +/// is disposed before sleeping to release the connection promptly. +/// +/// +public sealed class RetryPolicy : HttpPipelinePolicy +{ + private static readonly HashSet s_retryableStatusCodes = [408, 429, 500, 502, 503, 504]; + + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new . + /// + /// + /// The time source used to obtain the current UTC instant (for Retry-After HTTP-date + /// parsing) and to drive . + /// Defaults to when . + /// + public RetryPolicy(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public override PipelineStage Stage => PipelineStage.Retry; + + /// + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + var options = context.Options.Retry; + var attempt = 0; + + while (true) + { + context.AttemptNumber = attempt; + + Exception? caughtException = null; + + try + { + await continuation.RunAsync(context).ConfigureAwait(false); + } + catch (Exception ex) when (IsRetryableException(ex)) + { + caughtException = ex; + } + + var request = context.Request; + var canRetryRequest = CanRetryRequest(request, options); + + if (caughtException is not null) + { + // Exception path: re-throw if exhausted or not retryable. + if (attempt >= options.MaxRetryAttempts || !canRetryRequest) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(caughtException); + } + + await SleepAsync(null, attempt, options, context.CancellationToken) + .ConfigureAwait(false); + attempt++; + continue; + } + + // Success or non-retryable response path. + var response = context.Response; + + if (response is null + || attempt >= options.MaxRetryAttempts + || !canRetryRequest + || !IsRetryableStatus(response.Status.Code)) + { + // Leave context.Response as-is and return. + return; + } + + // Parse Retry-After before disposing the response. + TimeSpan? retryAfterDelay = null; + if (options.HonorRetryAfter) + { + var retryAfterHeader = response.Headers.Get(HttpHeaderName.WellKnown.RetryAfter.Original); + retryAfterDelay = ParseRetryAfter(retryAfterHeader); + } + + // Dispose the retryable response before sleeping to release the connection. + await response.DisposeAsync().ConfigureAwait(false); + context.Response = null; + + await SleepAsync(retryAfterDelay, attempt, options, context.CancellationToken) + .ConfigureAwait(false); + attempt++; + } + } + + /// + /// Returns when is a retryable transport + /// exception. is explicitly excluded so + /// cancellation always propagates. + /// + private static bool IsRetryableException(Exception ex) => + ex is ServiceRequestException or ServiceResponseException + && ex is not OperationCanceledException; + + private static bool IsRetryableStatus(int code) => + s_retryableStatusCodes.Contains(code); + + private static bool CanRetryRequest( + Http.Request.Request request, + RetryOptions options) + { + var bodyReplayable = request.Body is null || request.Body.IsReplayable; + return bodyReplayable + && (request.Method.IsIdempotent || options.RetryNonIdempotentWhenReplayable); + } + + /// + /// Parses a Retry-After header value. + /// Returns the delay as a , or when the + /// value cannot be interpreted. + /// + /// + /// Accepts two forms per RFC 7231 §7.1.3: + /// + /// An integer representing a delta-seconds value. + /// An HTTP-date whose distance from the current instant is the delay (floored at zero). + /// + /// + private TimeSpan? ParseRetryAfter(string? headerValue) + { + if (string.IsNullOrEmpty(headerValue)) + { + return null; + } + + // Delta-seconds form. + if (int.TryParse(headerValue, System.Globalization.NumberStyles.None, null, out var seconds)) + { + return TimeSpan.FromSeconds(seconds); + } + + // HTTP-date form (RFC 1123 / "r" format). + if (DateTimeOffset.TryParseExact( + headerValue, + "r", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var httpDate)) + { + var delta = httpDate - _timeProvider.GetUtcNow(); + return delta > TimeSpan.Zero ? delta : TimeSpan.Zero; + } + + return null; + } + + /// + /// Sleeps for the appropriate back-off delay, using when + /// supplied (from Retry-After) or full-jitter exponential back-off otherwise. + /// + private async Task SleepAsync( + TimeSpan? explicitDelay, + int attempt, + RetryOptions options, + CancellationToken cancellationToken) + { + TimeSpan delay; + + if (explicitDelay.HasValue) + { + delay = explicitDelay.Value; + } + else + { + // Full jitter: uniform in [0, min(BaseDelay * 2^attempt, MaxDelay)]. + // Guard the shift: cap at 30 to avoid overflow (2^30 ≈ 1e9 ms >> any MaxDelay). + var shift = Math.Min(attempt, 30); + var cap = TimeSpan.FromTicks( + Math.Min( + (long)(options.BaseDelay.Ticks * (1L << shift)), + options.MaxDelay.Ticks)); + delay = TimeSpan.FromTicks((long)(cap.Ticks * Random.Shared.NextDouble())); + } + + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, _timeProvider, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs new file mode 100644 index 0000000..750d4c3 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs @@ -0,0 +1,487 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class RetryPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Request MakeGetRequest() => Request.Get("https://api.example.com/v1/items"); + + private static Request MakePostRequest(bool replayable = false) + { + var body = replayable + ? RequestBody.FromBytes(ReadOnlyMemory.Empty) + : RequestBody.FromStream(new MemoryStream([1, 2, 3])); + return Request.Post("https://api.example.com/v1/items", body); + } + + private static DexpaceClientOptions MakeOptions( + int maxRetryAttempts = 3, + bool honorRetryAfter = true, + bool retryNonIdempotentWhenReplayable = false) + { + return new DexpaceClientOptions + { + Retry = new RetryOptions + { + MaxRetryAttempts = maxRetryAttempts, + BaseDelay = TimeSpan.FromMilliseconds(1), + MaxDelay = TimeSpan.FromMilliseconds(10), + HonorRetryAfter = honorRetryAfter, + RetryNonIdempotentWhenReplayable = retryNonIdempotentWhenReplayable, + } + }; + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsRetry() + { + Assert.Equal(PipelineStage.Retry, new RetryPolicy().Stage); + } + + // ------------------------------------------------------------------------- + // Successful responses (no retry needed) + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_SuccessOnFirstAttempt_ReturnsResponse_NoRetry() + { + var response200 = new Response(Status.Ok); + var transport = new ScriptedTransport(new object[] { response200 }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Retryable status codes + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_503ThenSuccess_RetriesAndReturnsSuccess() + { + var response503 = new Response(Status.ServiceUnavailable); + var response200 = new Response(Status.Ok); + var transport = new ScriptedTransport(new object[] { response503, response200 }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 3)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_Repeated503_ReturnsLastResponseAfterMaxAttempts() + { + // MaxRetryAttempts = 3 → 1 initial + 3 retries = 4 total calls. + var responses = Enumerable + .Repeat(new Response(Status.ServiceUnavailable), 4) + .ToArray(); + var transport = new ScriptedTransport(responses); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 3)); + + Assert.Equal(Status.ServiceUnavailable, result.Status); + Assert.Equal(4, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_500_IsRetried() + { + var transport = new ScriptedTransport( + new object[] { new Response(Status.InternalServerError), new Response(Status.Ok) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 1)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_429_IsRetried() + { + var transport = new ScriptedTransport( + new object[] { new Response(Status.TooManyRequests), new Response(Status.Ok) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 1)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Non-retryable status codes + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_400_ReturnsImmediately_NoRetry() + { + var response400 = new Response(Status.BadRequest); + var transport = new ScriptedTransport(new object[] { response400 }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + Assert.Equal(Status.BadRequest, result.Status); + Assert.Equal(1, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_404_ReturnsImmediately_NoRetry() + { + var transport = new ScriptedTransport(new object[] { new Response(Status.NotFound) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + Assert.Equal(Status.NotFound, result.Status); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Non-idempotent methods + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_Post_NonReplayableBody_503_NotRetried() + { + // POST with a stream body (not replayable) — must not retry. + var response503 = new Response(Status.ServiceUnavailable); + var transport = new ScriptedTransport(new object[] { response503 }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync( + MakePostRequest(replayable: false), + MakeOptions(retryNonIdempotentWhenReplayable: false)); + + Assert.Equal(Status.ServiceUnavailable, result.Status); + Assert.Equal(1, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_Post_ReplayableBody_RetryNonIdempotentEnabled_503_IsRetried() + { + var transport = new ScriptedTransport( + new object[] { new Response(Status.ServiceUnavailable), new Response(Status.Ok) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync( + MakePostRequest(replayable: true), + MakeOptions(maxRetryAttempts: 1, retryNonIdempotentWhenReplayable: true)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_Post_ReplayableBody_RetryNonIdempotentDisabled_503_NotRetried() + { + var transport = new ScriptedTransport(new object[] { new Response(Status.ServiceUnavailable) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync( + MakePostRequest(replayable: true), + MakeOptions(retryNonIdempotentWhenReplayable: false)); + + Assert.Equal(Status.ServiceUnavailable, result.Status); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Retryable exceptions + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_ServiceRequestException_OnGet_IsRetried() + { + var ex = new ServiceRequestException("DNS failure"); + var transport = new ScriptedTransport( + new object[] { ex, new Response(Status.Ok) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 1)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_ServiceRequestException_ExhaustsRetries_Rethrows() + { + var ex = new ServiceRequestException("persistent failure"); + // 1 initial + 3 retries = 4 total. + var script = Enumerable.Repeat(ex, 4).ToArray(); + var transport = new ScriptedTransport(script); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var thrown = await Assert.ThrowsAsync( + () => pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 3)).AsTask()); + + Assert.Same(ex, thrown); + Assert.Equal(4, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_ServiceResponseException_IsRetried() + { + var ex = new ServiceResponseException("connection dropped"); + var transport = new ScriptedTransport( + new object[] { ex, new Response(Status.Ok) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 1)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_ServiceRequestException_Post_NonReplayable_NotRetried() + { + var ex = new ServiceRequestException("DNS failure"); + var transport = new ScriptedTransport(new object[] { ex }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + await Assert.ThrowsAsync( + () => pipeline.SendAsync( + MakePostRequest(replayable: false), + MakeOptions(retryNonIdempotentWhenReplayable: false)).AsTask()); + + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Cancellation propagates + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_Cancellation_Propagates_NotSwallowed() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Transport throws OCE directly — must not be retried. + var transport = new ScriptedTransport( + new object[] { new OperationCanceledException(cts.Token) }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + await Assert.ThrowsAnyAsync( + () => pipeline.SendAsync(MakeGetRequest(), MakeOptions(), cts.Token).AsTask()); + + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Retry-After header + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_RetryAfterDeltaSeconds_IsHonored() + { + // Retry-After: 1 — parsed and honored; we just verify the retry happens. + var headers = new Headers.Builder().Set("Retry-After", "1").Build(); + var response503 = new Response(Status.ServiceUnavailable, headers); + var response200 = new Response(Status.Ok); + var transport = new ScriptedTransport(new object[] { response503, response200 }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 1)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_RetryAfterHttpDate_IsHonored() + { + var fixedNow = new DateTimeOffset(2026, 6, 14, 12, 0, 0, TimeSpan.Zero); + var future = fixedNow.AddSeconds(5).ToString("r"); + + var headers = new Headers.Builder().Set("Retry-After", future).Build(); + var response503 = new Response(Status.ServiceUnavailable, headers); + var response200 = new Response(Status.Ok); + var transport = new ScriptedTransport(new object[] { response503, response200 }); + + // Fake time pinned to fixedNow so the delta is parsed correctly. + var pipeline = new PipelineBuilder() + .Add(new RetryPolicy(new FixedTimeProvider(fixedNow))) + .Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 1)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task ProcessAsync_RetryAfterIgnored_WhenHonorRetryAfterFalse() + { + var headers = new Headers.Builder().Set("Retry-After", "60").Build(); + var response503 = new Response(Status.ServiceUnavailable, headers); + var response200 = new Response(Status.Ok); + var transport = new ScriptedTransport(new object[] { response503, response200 }); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(new InstantTimeProvider())).Build(transport); + + var opts = MakeOptions(maxRetryAttempts: 1, honorRetryAfter: false); + var result = await pipeline.SendAsync(MakeGetRequest(), opts); + + // Still retries (with jitter), just doesn't wait 60s. + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // AttemptNumber tracking + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_AttemptNumber_IsZeroOnFirstAttempt() + { + var recordedAttempts = new List(); + var capturePolicy = new CapturingAttemptPolicy(recordedAttempts); + var transport = new ScriptedTransport(new object[] { new Response(Status.Ok) }); + + var pipeline = new PipelineBuilder() + .Add(new RetryPolicy(new InstantTimeProvider())) + .Add(capturePolicy) + .Build(transport); + + await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + Assert.Single(recordedAttempts); + Assert.Equal(0, recordedAttempts[0]); + } + + [Fact] + public async Task ProcessAsync_AttemptNumber_IncrementsOnRetry() + { + var recordedAttempts = new List(); + var capturePolicy = new CapturingAttemptPolicy(recordedAttempts); + + var transport = new ScriptedTransport(new object[] + { + new Response(Status.ServiceUnavailable), + new Response(Status.ServiceUnavailable), + new Response(Status.Ok), + }); + + var pipeline = new PipelineBuilder() + .Add(new RetryPolicy(new InstantTimeProvider())) + .Add(capturePolicy) + .Build(transport); + + await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRetryAttempts: 3)); + + Assert.Equal([0, 1, 2], recordedAttempts); + } + + // ------------------------------------------------------------------------- + // Nested helpers + // ------------------------------------------------------------------------- + + private sealed class CapturingAttemptPolicy(List log) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add(context.AttemptNumber); + await continuation.RunAsync(context).ConfigureAwait(false); + } + } + + /// + /// A TimeProvider whose Task.Delay fires after 1 ms so tests don't block on real delays. + /// + private sealed class InstantTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => + new DateTimeOffset(2026, 6, 14, 12, 0, 0, TimeSpan.Zero); + + public override ITimer CreateTimer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period) => + base.CreateTimer(callback, state, TimeSpan.FromMilliseconds(1), period); + } + + /// + /// A TimeProvider pinned to a fixed instant (useful for Retry-After HTTP-date tests). + /// Task.Delay also fires after 1 ms. + /// + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + + public override ITimer CreateTimer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period) => + base.CreateTimer(callback, state, TimeSpan.FromMilliseconds(1), period); + } + + /// + /// A transport whose responses (or exceptions) are scripted per call index. + /// Entries may be instances, instances, + /// or Func<Response> factories. + /// + private sealed class ScriptedTransport : IAsyncHttpClient + { + private readonly List _script; + private int _callCount; + + public ScriptedTransport(IEnumerable script) + { + _script = script.ToList(); + } + + public int CallCount => _callCount; + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + var index = Interlocked.Increment(ref _callCount) - 1; + if (index >= _script.Count) + { + throw new InvalidOperationException($"Script ran out of entries at call {index + 1}."); + } + + var entry = _script[index]; + return entry switch + { + Response r => Task.FromResult(r), + Exception ex => Task.FromException(ex), + Func f => Task.FromResult(f()), + _ => throw new InvalidOperationException($"Unknown script entry type: {entry.GetType()}"), + }; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From 4b31a29a8b3a286ba76978c6f5e1e59962256181 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:55:39 +0300 Subject: [PATCH 11/17] feat: add RedirectPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 3xx redirect following per RedirectOptions: - 303 always becomes GET + no body - 301/302 on POST becomes GET + no body (legacy browser behavior) - 307/308 and non-POST 301/302 preserve method and body - HTTPS→HTTP downgrade blocked unless AllowHttpsToHttpDowngrade - Relative Location headers resolved against current request URL - MaxRedirects cap enforced; last 3xx returned to caller when exhausted - Non-replayable bodies block body-preserving redirects - Cross-origin hops strip Authorization and Cookie when StripSensitiveHeadersOnCrossOrigin Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/RedirectPolicy.cs | 156 ++++++ .../Pipeline/Policies/RedirectPolicyTests.cs | 479 ++++++++++++++++++ 2 files changed, 635 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs new file mode 100644 index 0000000..89d5b94 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// A redirect-following pipeline policy that processes 3xx responses according to the +/// configured . +/// +/// +/// +/// Followed status codes: 301, 302, 303, 307, 308. All other responses are returned +/// to the caller unchanged. +/// +/// +/// Method and body handling: +/// +/// 303 always becomes GET with no body. +/// 301 or 302 on a POST request becomes GET with no body (legacy browser behavior). +/// 307 and 308, and 301/302 on non-POST methods, preserve the original method and body. +/// +/// +/// +/// Non-replayable body guard: when the redirect would preserve the body and the body +/// is non-null with , +/// the redirect is not followed; the 3xx response is returned to the caller. +/// +/// +/// HTTPS → HTTP downgrade: refused unless +/// is +/// . +/// +/// +/// Cross-origin header stripping: when +/// is +/// and the new URL has a different origin (scheme/host/port), the +/// Authorization and Cookie headers are removed from the forwarded request. +/// +/// +public sealed class RedirectPolicy : HttpPipelinePolicy +{ + private static readonly HashSet s_redirectStatuses = [301, 302, 303, 307, 308]; + + /// + public override PipelineStage Stage => PipelineStage.Redirect; + + /// + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + var options = context.Options.Redirect; + var redirectCount = 0; + + while (true) + { + await continuation.RunAsync(context).ConfigureAwait(false); + + var response = context.Response; + + // No response (shouldn't happen) or non-redirect: hand off to caller. + if (response is null || !s_redirectStatuses.Contains(response.Status.Code)) + { + return; + } + + // Redirect count exhausted: leave the 3xx for the caller. + if (redirectCount >= options.MaxRedirects) + { + return; + } + + // Extract Location header. + var location = response.Headers.Get(HttpHeaderName.WellKnown.Location.Original); + if (string.IsNullOrEmpty(location)) + { + return; + } + + // Resolve Location (handles relative URIs) against current request URL. + var newUrl = new Uri(context.Request.Url, location); + + // HTTPS → HTTP downgrade guard. + if (context.Request.Url.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && newUrl.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !options.AllowHttpsToHttpDowngrade) + { + return; + } + + // Determine whether to preserve or drop method/body. + var statusCode = response.Status.Code; + var currentMethod = context.Request.Method; + bool dropBody; + Method newMethod; + + if (statusCode == 303) + { + // 303 → always GET + drop body. + newMethod = Method.Get; + dropBody = true; + } + else if ((statusCode == 301 || statusCode == 302) && currentMethod == Method.Post) + { + // 301/302 on POST → GET + drop body (legacy browser behavior). + newMethod = Method.Get; + dropBody = true; + } + else + { + // 307, 308, and 301/302 on non-POST: preserve method + body. + newMethod = currentMethod; + dropBody = false; + } + + // Non-replayable body guard: if body must be kept but cannot be replayed, stop. + if (!dropBody && context.Request.Body is { IsReplayable: false }) + { + return; + } + + // Cross-origin header stripping. + var newHeaders = context.Request.Headers; + if (options.StripSensitiveHeadersOnCrossOrigin && IsCrossOrigin(context.Request.Url, newUrl)) + { + newHeaders = newHeaders + .Without(HttpHeaderName.WellKnown.Authorization.Original) + .Without("Cookie"); + } + + // Dispose the current redirect response before issuing the next request. + await response.DisposeAsync().ConfigureAwait(false); + context.Response = null; + + context.Request = context.Request with + { + Url = newUrl, + Method = newMethod, + Headers = newHeaders, + Body = dropBody ? null : context.Request.Body, + }; + + redirectCount++; + } + } + + private static bool IsCrossOrigin(Uri current, Uri redirected) + { + // Origins differ when scheme, host, or port differ. + return !current.Scheme.Equals(redirected.Scheme, StringComparison.OrdinalIgnoreCase) + || !current.Host.Equals(redirected.Host, StringComparison.OrdinalIgnoreCase) + || current.Port != redirected.Port; + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs new file mode 100644 index 0000000..2c23f21 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs @@ -0,0 +1,479 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class RedirectPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Request MakeGetRequest(string url = "https://api.example.com/v1/items") => + Request.Get(url); + + private static Request MakePostRequest(string url = "https://api.example.com/v1/items", bool replayable = true) + { + var body = replayable + ? RequestBody.FromBytes(ReadOnlyMemory.Empty) + : RequestBody.FromStream(new MemoryStream([1, 2, 3])); + return Request.Post(url, body); + } + + private static DexpaceClientOptions MakeOptions( + int maxRedirects = 10, + bool allowHttpsToHttpDowngrade = false, + bool stripSensitiveHeadersOnCrossOrigin = true) => + new() + { + Redirect = new RedirectOptions + { + MaxRedirects = maxRedirects, + AllowHttpsToHttpDowngrade = allowHttpsToHttpDowngrade, + StripSensitiveHeadersOnCrossOrigin = stripSensitiveHeadersOnCrossOrigin, + } + }; + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsRedirect() + { + Assert.Equal(PipelineStage.Redirect, new RedirectPolicy().Stage); + } + + // ------------------------------------------------------------------------- + // 302 POST → GET (method downgrade) + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_302OnPost_BecomesGetWithNoBody() + { + // Arrange: POST → 302 to /v2/items → 200 OK + const string redirectUrl = "https://api.example.com/v2/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302(redirectUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + // Act + var result = await pipeline.SendAsync(MakePostRequest(), MakeOptions()); + + // Assert + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + + var secondRequest = transport.Requests[1]; + Assert.Equal(Method.Get, secondRequest.Method); + Assert.Null(secondRequest.Body); + Assert.Equal(new Uri(redirectUrl), secondRequest.Url); + } + + // ------------------------------------------------------------------------- + // 307 preserves method and body + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_307OnPost_PreservesMethodAndBody() + { + const string redirectUrl = "https://api.example.com/v2/items"; + var body = RequestBody.FromBytes(new byte[] { 1, 2, 3 }); + var originalRequest = Request.Post("https://api.example.com/v1/items", body); + + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect(307, redirectUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(originalRequest, MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + + var secondRequest = transport.Requests[1]; + Assert.Equal(Method.Post, secondRequest.Method); + Assert.NotNull(secondRequest.Body); + Assert.Equal(new Uri(redirectUrl), secondRequest.Url); + } + + // ------------------------------------------------------------------------- + // Relative Location resolves against current URL + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_RelativeLocation_ResolvesAgainstCurrentUrl() + { + // Arrange: GET /v1/items → 302 ../v2/items → 200 + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302("../v2/items"), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync( + MakeGetRequest("https://api.example.com/v1/items"), + MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + + // new Uri(new Uri("https://api.example.com/v1/items"), "../v2/items") = https://api.example.com/v2/items + Assert.Equal(new Uri("https://api.example.com/v2/items"), transport.Requests[1].Url); + } + + // ------------------------------------------------------------------------- + // MaxRedirects respected — stops and returns the last 3xx + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_MaxRedirects_StopsAndReturnsLast3xxResponse() + { + // MaxRedirects = 2: initial + 2 hops = 3 transport calls; 3rd call still 302 → return it + const string loc = "https://api.example.com/v2/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302(loc), + ScriptedTransport.Redirect302(loc), + ScriptedTransport.Redirect302(loc), + new Response(Status.Ok), // never reached + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions(maxRedirects: 2)); + + // Should stop after 2 redirects and return the last 3xx (the 3rd call) + Assert.Equal(302, result.Status.Code); + Assert.Equal(3, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Cross-origin hop strips Authorization header + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_CrossOriginRedirect_StripsAuthorizationAndCookieHeaders() + { + // Arrange: request with Authorization, redirect to different host + var headers = new Headers.Builder() + .Set("Authorization", "Bearer token123") + .Set("Cookie", "session=abc") + .Build(); + var request = Request.Create(Method.Get, "https://api.example.com/v1/items", headers); + + const string crossOriginUrl = "https://other.example.org/v1/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302(crossOriginUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(request, MakeOptions(stripSensitiveHeadersOnCrossOrigin: true)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + + var secondRequest = transport.Requests[1]; + Assert.Null(secondRequest.Headers.Get("authorization")); + Assert.Null(secondRequest.Headers.Get("cookie")); + } + + // ------------------------------------------------------------------------- + // Same-origin hop does NOT strip Authorization + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_SameOriginRedirect_KeepsAuthorizationHeader() + { + var headers = new Headers.Builder() + .Set("Authorization", "Bearer token123") + .Build(); + var request = Request.Create(Method.Get, "https://api.example.com/v1/items", headers); + + // Same host, different path + const string sameOriginUrl = "https://api.example.com/v2/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302(sameOriginUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(request, MakeOptions(stripSensitiveHeadersOnCrossOrigin: true)); + + Assert.Equal(Status.Ok, result.Status); + var secondRequest = transport.Requests[1]; + Assert.Equal("Bearer token123", secondRequest.Headers.Get("authorization")); + } + + // ------------------------------------------------------------------------- + // HTTPS → HTTP downgrade rejected when flag is false + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_HttpsToHttpDowngrade_NotFollowed_WhenFlagFalse() + { + const string httpUrl = "http://api.example.com/v1/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302(httpUrl), + new Response(Status.Ok), // never reached + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + // allowHttpsToHttpDowngrade defaults to false + var result = await pipeline.SendAsync( + MakeGetRequest("https://api.example.com/secure"), + MakeOptions(allowHttpsToHttpDowngrade: false)); + + // Should return the 302 without following + Assert.Equal(302, result.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // HTTPS → HTTP downgrade followed when flag is true + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_HttpsToHttpDowngrade_Followed_WhenFlagTrue() + { + const string httpUrl = "http://api.example.com/v1/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302(httpUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync( + MakeGetRequest("https://api.example.com/secure"), + MakeOptions(allowHttpsToHttpDowngrade: true)); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(2, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // 303 always becomes GET regardless of original method + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_303OnPut_BecomesGetWithNoBody() + { + const string redirectUrl = "https://api.example.com/v2/items"; + var body = RequestBody.FromBytes(new byte[] { 1, 2, 3 }); + var putRequest = Request.Create(Method.Put, "https://api.example.com/v1/items", body: body); + + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect(303, redirectUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(putRequest, MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + var secondRequest = transport.Requests[1]; + Assert.Equal(Method.Get, secondRequest.Method); + Assert.Null(secondRequest.Body); + } + + // ------------------------------------------------------------------------- + // 308 preserves method and body + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_308OnPost_PreservesMethodAndBody() + { + const string redirectUrl = "https://api.example.com/v2/items"; + var body = RequestBody.FromBytes(new byte[] { 1, 2, 3 }); + var originalRequest = Request.Post("https://api.example.com/v1/items", body); + + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect(308, redirectUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(originalRequest, MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + var secondRequest = transport.Requests[1]; + Assert.Equal(Method.Post, secondRequest.Method); + Assert.NotNull(secondRequest.Body); + } + + // ------------------------------------------------------------------------- + // 301 on POST → GET (legacy browser behavior) + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_301OnPost_BecomesGet() + { + const string redirectUrl = "https://api.example.com/v2/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect(301, redirectUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(MakePostRequest(), MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + var secondRequest = transport.Requests[1]; + Assert.Equal(Method.Get, secondRequest.Method); + Assert.Null(secondRequest.Body); + } + + // ------------------------------------------------------------------------- + // 301 on GET preserves GET + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_301OnGet_KeepsGet() + { + const string redirectUrl = "https://api.example.com/v2/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect(301, redirectUrl), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + var secondRequest = transport.Requests[1]; + Assert.Equal(Method.Get, secondRequest.Method); + } + + // ------------------------------------------------------------------------- + // Non-redirect status codes are passed through + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_200_IsPassedThrough_NoRedirect() + { + var transport = new ScriptedTransport([new Response(Status.Ok)]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + Assert.Equal(Status.Ok, result.Status); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Missing Location header stops redirect + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_302WithNoLocationHeader_StopsRedirect() + { + // 302 with no Location header — must not follow + var transport = new ScriptedTransport( + [ + new Response(Status.FromCode(302), Headers.Empty), + new Response(Status.Ok), // never reached + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + Assert.Equal(302, result.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Non-replayable body with body-preserving redirect is not followed + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_307OnPost_NonReplayableBody_NotFollowed() + { + const string redirectUrl = "https://api.example.com/v2/items"; + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect(307, redirectUrl), + new Response(Status.Ok), // never reached + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + // Non-replayable body — cannot re-send + var result = await pipeline.SendAsync(MakePostRequest(replayable: false), MakeOptions()); + + Assert.Equal(307, result.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Scripted transport helper + // ------------------------------------------------------------------------- + + private sealed class ScriptedTransport : IAsyncHttpClient + { + private readonly List _script; + private int _callCount; + private readonly List _requests = []; + + public ScriptedTransport(IEnumerable script) + { + _script = [.. script]; + } + + public int CallCount => _callCount; + public List Requests => _requests; + + public static Response Redirect302(string location) + { + var h = new Headers.Builder().Set("Location", location).Build(); + return new Response(Status.FromCode(302), h); + } + + public static Response Redirect(int statusCode, string location) + { + var h = new Headers.Builder().Set("Location", location).Build(); + return new Response(Status.FromCode(statusCode), h); + } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + _requests.Add(request); + var index = Interlocked.Increment(ref _callCount) - 1; + if (index >= _script.Count) + { + throw new InvalidOperationException($"Script ran out of entries at call {index + 1}."); + } + + var entry = _script[index]; + return entry switch + { + Response r => Task.FromResult(r), + Exception ex => Task.FromException(ex), + _ => throw new InvalidOperationException($"Unknown script entry type: {entry.GetType()}"), + }; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From c75e9c4745b93dc2911901c123ea4547a2ec21aa Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:55:52 +0300 Subject: [PATCH 12/17] fix: harden RetryPolicy backoff cap and cover delay computation - Overflow-safe exponential cap: saturate before shifting so a large BaseDelay never wraps negative. Guard is now baseTicks <= (maxTicks >> shift) ? baseTicks << shift : maxTicks, which is always in [0, MaxDelay] without a multiplication overflow. - Remove dead always-true conjunct (ex is not OperationCanceledException) from IsRetryableException; the type check already excludes OCE. Add a comment explaining the intentional exclusion. - Add RecordingTimeProvider that captures the dueTime passed to CreateTimer, and two new tests: one asserting all delays are in [0, MaxDelay] across three consecutive retries, and one confirming that a BaseDelay larger than MaxDelay never produces a negative delay. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/RetryPolicy.cs | 20 ++-- .../Pipeline/Policies/RetryPolicyTests.cs | 110 ++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs index 5d5afa9..1d9cc61 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/RetryPolicy.cs @@ -134,12 +134,12 @@ await SleepAsync(retryAfterDelay, attempt, options, context.CancellationToken) /// /// Returns when is a retryable transport - /// exception. is explicitly excluded so - /// cancellation always propagates. + /// exception. Only and + /// qualify; + /// is intentionally not matched (cancellation always propagates). /// private static bool IsRetryableException(Exception ex) => - ex is ServiceRequestException or ServiceResponseException - && ex is not OperationCanceledException; + ex is ServiceRequestException or ServiceResponseException; private static bool IsRetryableStatus(int code) => s_retryableStatusCodes.Contains(code); @@ -213,11 +213,15 @@ private async Task SleepAsync( { // Full jitter: uniform in [0, min(BaseDelay * 2^attempt, MaxDelay)]. // Guard the shift: cap at 30 to avoid overflow (2^30 ≈ 1e9 ms >> any MaxDelay). + // Saturate BEFORE multiplying: if BaseDelay.Ticks * 2^shift would overflow, + // clamp to MaxDelay.Ticks rather than letting the long wrap negative. var shift = Math.Min(attempt, 30); - var cap = TimeSpan.FromTicks( - Math.Min( - (long)(options.BaseDelay.Ticks * (1L << shift)), - options.MaxDelay.Ticks)); + var baseTicks = options.BaseDelay.Ticks; + var maxTicks = options.MaxDelay.Ticks; + var capTicks = baseTicks <= (maxTicks >> shift) + ? baseTicks << shift + : maxTicks; + var cap = TimeSpan.FromTicks(Math.Min(capTicks, maxTicks)); delay = TimeSpan.FromTicks((long)(cap.Ticks * Random.Shared.NextDouble())); } diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs index 750d4c3..dcc418f 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs @@ -415,6 +415,89 @@ public override async ValueTask ProcessAsync(PipelineContext context, PipelineRu } } + // ------------------------------------------------------------------------- + // Delay computation — backoff cap and no-overflow + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_DelayPerAttempt_IsNonNegativeAndNeverExceedsMaxDelay() + { + // Three consecutive retryable responses → three delays recorded. + // Each must be in [0, MaxDelay]. + var baseDelay = TimeSpan.FromMilliseconds(5); + var maxDelay = TimeSpan.FromMilliseconds(50); + + var transport = new ScriptedTransport( + [ + new Response(Status.ServiceUnavailable), + new Response(Status.ServiceUnavailable), + new Response(Status.ServiceUnavailable), + new Response(Status.Ok), + ]); + var recording = new RecordingTimeProvider(); + + var options = new DexpaceClientOptions + { + Retry = new RetryOptions + { + MaxRetryAttempts = 3, + BaseDelay = baseDelay, + MaxDelay = maxDelay, + HonorRetryAfter = false, + } + }; + + var pipeline = new PipelineBuilder().Add(new RetryPolicy(recording)).Build(transport); + await pipeline.SendAsync(MakeGetRequest(), options); + + // Three retries → three delays recorded. + Assert.Equal(3, recording.RequestedDelays.Count); + foreach (var (attemptIndex, delay) in recording.RequestedDelays) + { + Assert.True(delay >= TimeSpan.Zero, $"Delay at timer call {attemptIndex} was negative: {delay}"); + Assert.True(delay <= maxDelay, $"Delay at timer call {attemptIndex} exceeded MaxDelay ({maxDelay}): {delay}"); + } + } + + [Fact] + public async Task ProcessAsync_VeryLargeBaseDelay_NeverOverflows_DelayClampedAtMaxDelay() + { + // BaseDelay large enough that naive multiplication overflows a long for attempt >= 1. + // MaxDelay is the same; the overflow-safe cap must clamp cleanly. + var baseDelay = TimeSpan.FromHours(1); // 36_000_000_000_0 ticks — shift by 1 → overflow + var maxDelay = TimeSpan.FromSeconds(30); + + var transport = new ScriptedTransport( + [ + new Response(Status.ServiceUnavailable), + new Response(Status.ServiceUnavailable), + new Response(Status.Ok), + ]); + var recording = new RecordingTimeProvider(); + + var options = new DexpaceClientOptions + { + Retry = new RetryOptions + { + MaxRetryAttempts = 2, + BaseDelay = baseDelay, + MaxDelay = maxDelay, + HonorRetryAfter = false, + } + }; + + var pipeline = new PipelineBuilder().Add(new RetryPolicy(recording)).Build(transport); + await pipeline.SendAsync(MakeGetRequest(), options); + + // Every recorded delay must be non-negative and at most MaxDelay. + Assert.Equal(2, recording.RequestedDelays.Count); + Assert.All(recording.RequestedDelays, pair => + { + Assert.True(pair.Item2 >= TimeSpan.Zero, $"Delay was negative: {pair.Item2}"); + Assert.True(pair.Item2 <= maxDelay, $"Delay exceeded MaxDelay ({maxDelay}): {pair.Item2}"); + }); + } + /// /// A TimeProvider whose Task.Delay fires after 1 ms so tests don't block on real delays. /// @@ -447,6 +530,33 @@ public override ITimer CreateTimer( base.CreateTimer(callback, state, TimeSpan.FromMilliseconds(1), period); } + /// + /// A TimeProvider that records every (attemptNumber, dueTime) pair requested via + /// so tests can assert on actual delays passed by the retry policy. + /// + private sealed class RecordingTimeProvider : TimeProvider + { + private readonly List<(int Attempt, TimeSpan Delay)> _delays = []; + private int _timerCallCount; + + public List<(int Attempt, TimeSpan Delay)> RequestedDelays => _delays; + + public override DateTimeOffset GetUtcNow() => + new DateTimeOffset(2026, 6, 14, 12, 0, 0, TimeSpan.Zero); + + public override ITimer CreateTimer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period) + { + var attempt = Interlocked.Increment(ref _timerCallCount) - 1; + _delays.Add((attempt, dueTime)); + // Fire immediately so the test doesn't block. + return base.CreateTimer(callback, state, TimeSpan.FromMilliseconds(1), period); + } + } + /// /// A transport whose responses (or exceptions) are scripted per call index. /// Entries may be instances, instances, From 6f53839df51f26644b288736c003bb8d1ee16305 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:04:40 +0300 Subject: [PATCH 13/17] fix: handle a malformed redirect Location gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `new Uri(context.Request.Url, location)` with `Uri.TryCreate`; when the server-supplied Location value is not a valid URI the parse fails silently and the redirect is treated as non-followable, leaving the 3xx response in context.Response for the caller. Previously the raw UriFormatException escaped the pipeline unhandled. Added tests: - 3xx with a malformed Location (invalid IPv6 literal) → no exception, redirect not followed, 3xx returned, transport called once. - Chained A→B→C→200 multi-hop redirect → ends at 200 with 3 transport calls and request URLs progressing A, B, C in order. Co-Authored-By: Claude Sonnet 4.6 --- .../Pipeline/Policies/RedirectPolicy.cs | 7 ++- .../Pipeline/Policies/RedirectPolicyTests.cs | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs index 89d5b94..1bdeffb 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/RedirectPolicy.cs @@ -80,7 +80,12 @@ public override async ValueTask ProcessAsync(PipelineContext context, PipelineRu } // Resolve Location (handles relative URIs) against current request URL. - var newUrl = new Uri(context.Request.Url, location); + // Use TryCreate so a malformed Location value from the server doesn't throw a raw + // UriFormatException through the pipeline — treat it as non-followable instead. + if (!Uri.TryCreate(context.Request.Url, location, out var newUrl)) + { + return; + } // HTTPS → HTTP downgrade guard. if (context.Request.Url.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs index 2c23f21..390b7c7 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RedirectPolicyTests.cs @@ -426,6 +426,68 @@ public async Task ProcessAsync_307OnPost_NonReplayableBody_NotFollowed() Assert.Equal(1, transport.CallCount); } + // ------------------------------------------------------------------------- + // Malformed Location header — must not throw, must not follow + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_MalformedLocation_NoExceptionEscapes_3xxReturnedUnfollowed() + { + // "http://[bad" has an invalid IPv6 literal that causes new Uri(...) to throw + // UriFormatException but Uri.TryCreate to return false — that is exactly the + // boundary the fix guards. + var malformedHeaders = new Headers.Builder().Set("Location", "http://[bad").Build(); + var transport = new ScriptedTransport( + [ + new Response(Status.FromCode(302), malformedHeaders), + new Response(Status.Ok), // must never be reached + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + // No exception should escape the pipeline. + var result = await pipeline.SendAsync(MakeGetRequest(), MakeOptions()); + + // Redirect is not followed — the 3xx comes back to the caller. + Assert.Equal(302, result.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + // ------------------------------------------------------------------------- + // Multi-hop chained redirect A→B→C→200 + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_ChainedMultiHopRedirect_FollowsAllHops_EndsAt200() + { + // A → B → C → 200 + const string urlA = "https://api.example.com/a"; + const string urlB = "https://api.example.com/b"; + const string urlC = "https://api.example.com/c"; + + var transport = new ScriptedTransport( + [ + ScriptedTransport.Redirect302(urlB), // A → B + ScriptedTransport.Redirect302(urlC), // B → C + new Response(Status.Ok), // C → 200 + ]); + var pipeline = new PipelineBuilder().Add(new RedirectPolicy()).Build(transport); + + var result = await pipeline.SendAsync( + MakeGetRequest(urlA), + MakeOptions(maxRedirects: 10)); + + // Final response is 200. + Assert.Equal(Status.Ok, result.Status); + + // Transport was called exactly 3 times. + Assert.Equal(3, transport.CallCount); + + // URLs progressed A → B → C. + Assert.Equal(new Uri(urlA), transport.Requests[0].Url); + Assert.Equal(new Uri(urlB), transport.Requests[1].Url); + Assert.Equal(new Uri(urlC), transport.Requests[2].Url); + } + // ------------------------------------------------------------------------- // Scripted transport helper // ------------------------------------------------------------------------- From c16529939e1d37d8da58bfded57fb2dc8b994dd0 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:04:52 +0300 Subject: [PATCH 14/17] test: make RetryPolicy backoff tests guard the real regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two prior backoff tests used BaseDelay/attempt values too small to distinguish the fixed overflow-safe cap from the old code. Replaced: - Overflow guard test: BaseDelay=700 days (~6e17 ticks); at shift=4 the old `baseTicks << shift` overflows long and wraps negative, so the jitter cap becomes ≤ 0 and Task.Delay is skipped, leaving fewer timer entries than expected. The test now uses MaxRetryAttempts=5 (reaching shift=4) and asserts that all 5 recorded delays are in [0, MaxDelay=30 s], which fails against the old code. - Delay-bound test: BaseDelay=MaxDelay=30 s so the cap equals MaxDelay for every attempt. The assertion `delay <= MaxDelay` is now governed by the cap logic rather than by BaseDelay being naturally small, making it meaningful against an implementation that forgets to apply the cap. Both tests use the existing RecordingTimeProvider so no real time passes. Co-Authored-By: Claude Sonnet 4.6 --- .../Pipeline/Policies/RetryPolicyTests.cs | 97 ++++++++++++------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs index dcc418f..c1df46a 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/RetryPolicyTests.cs @@ -420,81 +420,104 @@ public override async ValueTask ProcessAsync(PipelineContext context, PipelineRu // ------------------------------------------------------------------------- [Fact] - public async Task ProcessAsync_DelayPerAttempt_IsNonNegativeAndNeverExceedsMaxDelay() + public async Task ProcessAsync_OverflowGuard_AllRecordedDelaysAreNonNegativeAndBoundedByMaxDelay() { - // Three consecutive retryable responses → three delays recorded. - // Each must be in [0, MaxDelay]. - var baseDelay = TimeSpan.FromMilliseconds(5); - var maxDelay = TimeSpan.FromMilliseconds(50); - - var transport = new ScriptedTransport( - [ - new Response(Status.ServiceUnavailable), - new Response(Status.ServiceUnavailable), - new Response(Status.ServiceUnavailable), - new Response(Status.Ok), - ]); + // Choose BaseDelay and MaxRetryAttempts such that the OLD formula + // BaseDelay.Ticks * (1L << shift) + // would overflow long and wrap negative on at least one attempt. + // + // TimeSpan.FromDays(700).Ticks ≈ 6.05e17. At shift = 4 (attempt 4): + // 6.05e17 << 4 = ~9.7e18, which is > long.MaxValue (9.22e18) → wraps negative. + // With MaxRetryAttempts = 5 we get shifts 0..4, covering the overflow at shift 4. + // + // The overflow-safe implementation clamps to MaxDelay instead of overflowing, + // so every recorded delay must be non-negative and <= MaxDelay. + // Against the old code, at least one attempt would compute a negative cap, + // causing the jitter draw to be zero and skipping Task.Delay entirely — which + // would mean a delay of TimeSpan.Zero being recorded by RecordingTimeProvider + // after a shift that should have been clamped to MaxDelay. The assertion that + // ALL recorded delays are > TimeSpan.Zero would catch that (jitter from + // cap = 0 → delay = 0 → not recorded, but the assertion on Count would fail + // because a skipped delay leaves fewer entries). We therefore assert both: + // (a) the count equals MaxRetryAttempts, and + // (b) every entry is in [0, MaxDelay]. + var baseDelay = TimeSpan.FromDays(700); // ~6e17 ticks; shift≥4 overflows old code + var maxDelay = TimeSpan.FromSeconds(30); // small cap — meaningful upper bound + + // 1 initial + 5 retries = 6 transport calls. + var responses = Enumerable + .Repeat(new Response(Status.ServiceUnavailable), 5) + .Append(new Response(Status.Ok)) + .ToArray(); var recording = new RecordingTimeProvider(); var options = new DexpaceClientOptions { Retry = new RetryOptions { - MaxRetryAttempts = 3, + MaxRetryAttempts = 5, BaseDelay = baseDelay, MaxDelay = maxDelay, HonorRetryAfter = false, } }; - var pipeline = new PipelineBuilder().Add(new RetryPolicy(recording)).Build(transport); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(recording)).Build(transport: new ScriptedTransport(responses)); await pipeline.SendAsync(MakeGetRequest(), options); - // Three retries → three delays recorded. - Assert.Equal(3, recording.RequestedDelays.Count); - foreach (var (attemptIndex, delay) in recording.RequestedDelays) + // Five retries → five delays recorded (one per retry, not per attempt). + Assert.Equal(5, recording.RequestedDelays.Count); + Assert.All(recording.RequestedDelays, pair => { - Assert.True(delay >= TimeSpan.Zero, $"Delay at timer call {attemptIndex} was negative: {delay}"); - Assert.True(delay <= maxDelay, $"Delay at timer call {attemptIndex} exceeded MaxDelay ({maxDelay}): {delay}"); - } + Assert.True(pair.Item2 >= TimeSpan.Zero, + $"Delay at timer call {pair.Item1} was negative: {pair.Item2}"); + Assert.True(pair.Item2 <= maxDelay, + $"Delay at timer call {pair.Item1} exceeded MaxDelay ({maxDelay}): {pair.Item2}"); + }); } [Fact] - public async Task ProcessAsync_VeryLargeBaseDelay_NeverOverflows_DelayClampedAtMaxDelay() + public async Task ProcessAsync_DelayBound_LaterAttemptsArePinnedToMaxDelay() { - // BaseDelay large enough that naive multiplication overflows a long for attempt >= 1. - // MaxDelay is the same; the overflow-safe cap must clamp cleanly. - var baseDelay = TimeSpan.FromHours(1); // 36_000_000_000_0 ticks — shift by 1 → overflow + // Choose BaseDelay == MaxDelay so that even attempt 0 (shift = 0) computes + // cap = min(BaseDelay * 1, MaxDelay) = MaxDelay. + // This means the jitter-cap is MaxDelay for EVERY attempt, and the assertion + // delay <= MaxDelay + // is meaningful — it is governed by the cap logic, not by BaseDelay being small. + // Against a naive implementation that forgets to apply the cap, some draws + // (from a cap larger than MaxDelay) could land above MaxDelay. var maxDelay = TimeSpan.FromSeconds(30); + var baseDelay = maxDelay; // BaseDelay == MaxDelay → cap == MaxDelay for all attempts - var transport = new ScriptedTransport( - [ - new Response(Status.ServiceUnavailable), - new Response(Status.ServiceUnavailable), - new Response(Status.Ok), - ]); + // 1 initial + 3 retries = 4 transport calls. + var responses = Enumerable + .Repeat(new Response(Status.ServiceUnavailable), 3) + .Append(new Response(Status.Ok)) + .ToArray(); var recording = new RecordingTimeProvider(); var options = new DexpaceClientOptions { Retry = new RetryOptions { - MaxRetryAttempts = 2, + MaxRetryAttempts = 3, BaseDelay = baseDelay, MaxDelay = maxDelay, HonorRetryAfter = false, } }; - var pipeline = new PipelineBuilder().Add(new RetryPolicy(recording)).Build(transport); + var pipeline = new PipelineBuilder().Add(new RetryPolicy(recording)).Build(transport: new ScriptedTransport(responses)); await pipeline.SendAsync(MakeGetRequest(), options); - // Every recorded delay must be non-negative and at most MaxDelay. - Assert.Equal(2, recording.RequestedDelays.Count); + // Three retries → three delays recorded. + Assert.Equal(3, recording.RequestedDelays.Count); Assert.All(recording.RequestedDelays, pair => { - Assert.True(pair.Item2 >= TimeSpan.Zero, $"Delay was negative: {pair.Item2}"); - Assert.True(pair.Item2 <= maxDelay, $"Delay exceeded MaxDelay ({maxDelay}): {pair.Item2}"); + Assert.True(pair.Item2 >= TimeSpan.Zero, + $"Delay at timer call {pair.Item1} was negative: {pair.Item2}"); + Assert.True(pair.Item2 <= maxDelay, + $"Delay at timer call {pair.Item1} exceeded MaxDelay ({maxDelay}): {pair.Item2}"); }); } From a9ed9678ff8d7cfc5a2127cb8e8925d5de5e717b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:13:45 +0300 Subject: [PATCH 15/17] feat: add InstrumentationPolicy (tracing, metrics, logging) Adds InstrumentationPolicy at PipelineStage.Diagnostics. Per attempt: starts a client-kind Activity from DexpaceDiagnostics.ActivitySource with OTel HTTP semantic tags (http.request.method, url.full redacted via UrlRedactor, server.address, server.port, http.response.status_code, http.request.resend_count, error.type on exception). Records http.client.request.duration histogram (seconds) and http.client.active_requests UpDownCounter from the shared Meter. Emits zero-alloc source-generated LoggerMessage events at Debug/Warning with the redacted URL; secrets are never logged. Adds Microsoft.Extensions.Logging.Abstractions 9.0.5 to Directory.Packages.props and references it (no Version) in Core. Co-Authored-By: Claude Sonnet 4.6 --- Directory.Packages.props | 1 + src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj | 1 + .../Policies/InstrumentationPolicy.cs | 160 ++++++++ .../Policies/InstrumentationPolicyTests.cs | 373 ++++++++++++++++++ 4 files changed, 535 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 23cfad5..153c265 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj b/src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj index c0fcc82..157a712 100644 --- a/src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj +++ b/src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs new file mode 100644 index 0000000..2ad3f00 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs @@ -0,0 +1,160 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Dexpace.Sdk.Core.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// A per-attempt diagnostics policy that records distributed tracing spans, metrics, and +/// structured log events for each HTTP request attempt. +/// +/// +/// +/// Tracing. A client-kind is started from +/// for each attempt. The activity name is the +/// HTTP method (low cardinality). OTel HTTP semantic-convention tags are attached: +/// http.request.method, url.full (redacted), server.address, +/// server.port, http.response.status_code, and http.request.resend_count. +/// On exception, error.type is set and the activity status is +/// . When no listener is registered, +/// ActivitySource.StartActivity returns and the hot path +/// allocates nothing for tracing. +/// +/// +/// Metrics. Two instruments are recorded per attempt: +/// +/// +/// http.client.request.duration in seconds. +/// +/// +/// http.client.active_requests incremented before +/// the send and decremented after (in a finally block). +/// +/// +/// +/// +/// Logging. Structured events are emitted at +/// with the redacted URL. Secrets are never logged. +/// +/// +public sealed partial class InstrumentationPolicy : HttpPipelinePolicy +{ + // Instruments are created once from the shared Meter. + private static readonly Histogram s_requestDuration = + DexpaceDiagnostics.Meter.CreateHistogram( + "http.client.request.duration", + unit: "s", + description: "Duration of HTTP client requests."); + + private static readonly UpDownCounter s_activeRequests = + DexpaceDiagnostics.Meter.CreateUpDownCounter( + "http.client.active_requests", + unit: "{request}", + description: "Number of HTTP requests currently in flight."); + + private static readonly UrlRedactor s_redactor = new(); + + private readonly ILogger _logger; + + /// + /// Initializes a new . + /// + /// + /// The logger to write request/response events to. Defaults to + /// when . + /// + public InstrumentationPolicy(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + public override PipelineStage Stage => PipelineStage.Diagnostics; + + /// + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + var request = context.Request; + var method = request.Method.Name; + var redactedUrl = s_redactor.Redact(request.Url); + + // Start a client-kind Activity only when there are listeners; null if none. + using var activity = DexpaceDiagnostics.ActivitySource.StartActivity( + method, + ActivityKind.Client); + + if (activity is not null) + { + activity.SetTag("http.request.method", method); + activity.SetTag("url.full", redactedUrl); + activity.SetTag("server.address", request.Url.Host); + activity.SetTag("server.port", request.Url.IsDefaultPort ? -1 : request.Url.Port); + activity.SetTag("http.request.resend_count", context.AttemptNumber); + context.Activity = activity; + } + + LogSendingRequest(_logger, method, redactedUrl); + + var sw = Stopwatch.StartNew(); + s_activeRequests.Add(1); + + try + { + await continuation.RunAsync(context).ConfigureAwait(false); + + var statusCode = context.Response?.Status.Code; + if (activity is not null && statusCode.HasValue) + { + activity.SetTag("http.response.status_code", statusCode.Value); + } + + LogReceivedResponse(_logger, method, statusCode, redactedUrl); + } + catch (Exception ex) + { + if (activity is not null) + { + activity.SetTag("error.type", ex.GetType().FullName); + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + } + + LogRequestFailed(_logger, ex, method, redactedUrl, ex.GetType().Name); + + throw; + } + finally + { + s_activeRequests.Add(-1); + s_requestDuration.Record(sw.Elapsed.TotalSeconds); + // Null out context.Activity so downstream observers don't access a disposed span. + context.Activity = null; + } + } + + // ─── Source-generated zero-alloc logger messages ────────────────────────── + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Debug, + Message = "Sending {Method} request to {Url}")] + private static partial void LogSendingRequest(ILogger logger, string method, string url); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Debug, + Message = "Received {Method} response {StatusCode} from {Url}")] + private static partial void LogReceivedResponse(ILogger logger, string method, int? statusCode, string url); + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "Request {Method} to {Url} failed with {ErrorType}")] + private static partial void LogRequestFailed(ILogger logger, Exception ex, string method, string url, string errorType); +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs new file mode 100644 index 0000000..c2a33bf --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs @@ -0,0 +1,373 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Diagnostics; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class InstrumentationPolicyTests : IDisposable +{ + private readonly ActivityListener _listener; + private readonly List _activities = []; + + public InstrumentationPolicyTests() + { + _listener = new ActivityListener + { + ShouldListenTo = src => src.Name == "Dexpace.Sdk", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = a => _activities.Add(a), + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() => _listener.Dispose(); + + // ─── helpers ───────────────────────────────────────────────────────────── + + private static Request MakeRequest(Uri url) => + new Request(Method.Get, url); + + private static DexpaceClientOptions DefaultOptions() => new(); + + // Runs the policy with a scripted transport. + private static async Task RunAsync( + HttpPipelinePolicy policy, + Request request, + IAsyncHttpClient transport) + { + var pipeline = new PipelineBuilder().Add(policy).Build(transport); + return await pipeline.SendAsync(request, DefaultOptions()); + } + + // ─── Stage ─────────────────────────────────────────────────────────────── + + [Fact] + public void Stage_IsDiagnostics() + { + Assert.Equal(PipelineStage.Diagnostics, new InstrumentationPolicy().Stage); + } + + // ─── Activity tracing ──────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_StartsActivity_WithClientKind() + { + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + var url = new Uri("https://api.example.com/v1/items"); + + await RunAsync(policy, MakeRequest(url), transport); + + var activity = Assert.Single(_activities); + Assert.Equal(ActivityKind.Client, activity.Kind); + } + + [Fact] + public async Task ProcessAsync_ActivityName_IsHttpMethod() + { + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + var url = new Uri("https://api.example.com/v1/items"); + + await RunAsync(policy, MakeRequest(url), transport); + + var activity = Assert.Single(_activities); + Assert.Equal("GET", activity.DisplayName); + } + + [Fact] + public async Task ProcessAsync_Activity_HasExpectedOtelTags() + { + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + var url = new Uri("https://api.example.com:8443/v1/items"); + + await RunAsync(policy, MakeRequest(url), transport); + + var activity = Assert.Single(_activities); + Assert.Equal("GET", activity.GetTagItem("http.request.method")); + Assert.NotNull(activity.GetTagItem("url.full")); + Assert.Equal("api.example.com", activity.GetTagItem("server.address")); + Assert.Equal(8443, activity.GetTagItem("server.port")); + Assert.Equal(200, activity.GetTagItem("http.response.status_code")); + } + + [Fact] + public async Task ProcessAsync_UrlFull_IsSensitiveParamRedacted() + { + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + // "api_key" is in UrlRedactor.DefaultSensitiveParams + var url = new Uri("https://api.example.com/v1/items?api_key=SECRET123&page=2"); + + await RunAsync(policy, MakeRequest(url), transport); + + var activity = Assert.Single(_activities); + var urlFull = activity.GetTagItem("url.full") as string; + Assert.NotNull(urlFull); + Assert.DoesNotContain("SECRET123", urlFull); + Assert.Contains("REDACTED", urlFull); + // Non-sensitive param should be preserved + Assert.Contains("page=2", urlFull); + } + + [Fact] + public async Task ProcessAsync_AttemptNumber_SetOnResendCountTag() + { + // Use RetryPolicy + InstrumentationPolicy so AttemptNumber increments + var transport = new ScriptedTransport([ + new Response(Status.ServiceUnavailable), + new Response(Status.Ok), + ]); + var pipeline = new PipelineBuilder() + .Add(new RetryPolicy(new InstantTimeProvider())) + .Add(new InstrumentationPolicy()) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(new Uri("https://api.example.com/")), DefaultOptions()); + + // Two activities should have been started: attempt 0 and attempt 1 + Assert.Equal(2, _activities.Count); + Assert.Equal(0, _activities[0].GetTagItem("http.request.resend_count")); + Assert.Equal(1, _activities[1].GetTagItem("http.request.resend_count")); + } + + [Fact] + public async Task ProcessAsync_ActivitySetOnContext_DuringContinuation() + { + // CapturingPolicy must run AFTER InstrumentationPolicy sets context.Activity. + // InstrumentationPolicy is at Diagnostics=600; we use stage 650 so it sorts after. + Activity? capturedActivity = null; + var capturingPolicy = new CapturingPolicy(ctx => capturedActivity = ctx.Activity, stage: (PipelineStage)650); + + var transport = new StaticTransport(new Response(Status.Ok)); + var pipeline = new PipelineBuilder() + .Add(new InstrumentationPolicy()) + .Add(capturingPolicy) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(new Uri("https://api.example.com/")), DefaultOptions()); + + Assert.NotNull(capturedActivity); + } + + [Fact] + public async Task ProcessAsync_Exception_SetsErrorTypeTag_AndActivityStatusError() + { + var ex = new InvalidOperationException("boom"); + var transport = new ThrowingTransport(ex); + var policy = new InstrumentationPolicy(); + + var thrown = await Assert.ThrowsAsync( + () => RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport)); + + Assert.Same(ex, thrown); + + var activity = Assert.Single(_activities); + var errorType = activity.GetTagItem("error.type") as string; + Assert.NotNull(errorType); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + } + + [Fact] + public async Task ProcessAsync_NoListener_DoesNotThrow() + { + // Dispose the listener — now no listener is active, StartActivity returns null. + _listener.Dispose(); + + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + + // Must not throw even when Activity is null + var result = await RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport); + Assert.Equal(Status.Ok, result.Status); + } + + // ─── Metrics ───────────────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_RecordsDurationHistogram() + { + double? recordedDuration = null; + using var meterListener = new MeterListener(); + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Dexpace.Sdk" && instrument.Name == "http.client.request.duration") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((_, measurement, _, _) => + { + recordedDuration = measurement; + }); + meterListener.Start(); + + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + await RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport); + + meterListener.RecordObservableInstruments(); + Assert.NotNull(recordedDuration); + Assert.True(recordedDuration >= 0, "Duration must be non-negative"); + } + + [Fact] + public async Task ProcessAsync_ActiveRequestsCounter_IncrementsThenDecrements() + { + long maxObserved = 0; + long lastObserved = 0; + using var meterListener = new MeterListener(); + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Dexpace.Sdk" && instrument.Name == "http.client.active_requests") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((_, measurement, _, _) => + { + lastObserved += measurement; + if (lastObserved > maxObserved) + { + maxObserved = lastObserved; + } + }); + meterListener.Start(); + + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + await RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport); + + meterListener.RecordObservableInstruments(); + // After completion the counter should be back to 0 (net effect) + Assert.Equal(0, lastObserved); + // And at some point during the call it was positive + Assert.True(maxObserved > 0, "Active requests should have been incremented"); + } + + // ─── Logging ───────────────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_LogsStructuredEvent_WithRedactedUrl() + { + var logger = new RecordingLogger(); + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(logger); + var url = new Uri("https://api.example.com/v1/items?api_key=SECRET&x=1"); + + await RunAsync(policy, MakeRequest(url), transport); + + Assert.NotEmpty(logger.Entries); + // Verify that no log entry contains the secret + foreach (var (_, message) in logger.Entries) + { + Assert.DoesNotContain("SECRET", message); + } + // Verify the redacted marker is present in at least one entry + Assert.Contains(logger.Entries, e => e.Message.Contains("REDACTED")); + } + + [Fact] + public async Task ProcessAsync_NullLogger_DoesNotThrow() + { + // Passing null logger should fall back to NullLogger.Instance + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(null); + var result = await RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport); + Assert.Equal(Status.Ok, result.Status); + } + + // ─── Nested helpers ────────────────────────────────────────────────────── + + private sealed class StaticTransport(Response response) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(response); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class ThrowingTransport(Exception ex) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromException(ex); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class ScriptedTransport : IAsyncHttpClient + { + private readonly List _script; + private int _index; + + public ScriptedTransport(IEnumerable script) => _script = [.. script]; + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + var entry = _script[_index++]; + return entry switch + { + Response r => Task.FromResult(r), + Exception ex => Task.FromException(ex), + _ => throw new InvalidOperationException($"Unknown script entry: {entry.GetType()}"), + }; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class CapturingPolicy(Action capture, PipelineStage stage = PipelineStage.PerAttempt) : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + capture(context); + await continuation.RunAsync(context).ConfigureAwait(false); + } + } + + private sealed class InstantTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => + new DateTimeOffset(2026, 6, 14, 12, 0, 0, TimeSpan.Zero); + + public override ITimer CreateTimer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period) => + base.CreateTimer(callback, state, TimeSpan.FromMilliseconds(1), period); + } + + private sealed class RecordingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + } +} From 2ef5afa29679f7f97b45d2401b96c92381367b69 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:16:46 +0300 Subject: [PATCH 16/17] feat: add DexpacePipeline.CreateDefault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the DexpacePipeline static factory. CreateDefault assembles the full default policy set via PipelineBuilder: OperationPolicy, RedirectPolicy, IdempotencyPolicy, ClientIdentityPolicy, RetryPolicy(timeProvider), SetDatePolicy(timeProvider), optional authPolicy, and InstrumentationPolicy(logger) — then calls Build(transport). PipelineBuilder sorts by stage at Build time, so insertion order does not affect the final chain ordering. Tests verify end-to-end request flow (200), retry wiring (503→200, exhaust), redirect wiring (302→200), and optional auth policy injection. InstrumentationPolicyTests and DexpacePipelineTests are placed in the "Instrumentation" xUnit collection to prevent parallel execution and cross-test ActivitySource leakage. Co-Authored-By: Claude Sonnet 4.6 --- .../Pipeline/DexpacePipeline.cs | 67 +++++++ .../Pipeline/DexpacePipelineTests.cs | 185 ++++++++++++++++++ .../Policies/InstrumentationPolicyTests.cs | 5 + 3 files changed, 257 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/DexpacePipeline.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/DexpacePipelineTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/DexpacePipeline.cs b/src/Dexpace.Sdk.Core/Pipeline/DexpacePipeline.cs new file mode 100644 index 0000000..15f72cf --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/DexpacePipeline.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Microsoft.Extensions.Logging; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Factory for the default Dexpace SDK HTTP pipeline. +/// +/// +/// assembles the standard policy set in the correct stage order: +/// +/// → +/// → optional auth policy → → transport. +/// Because sorts by at build +/// time, the insertion order of the Add calls here does not affect the final ordering. +/// +public static class DexpacePipeline +{ + /// + /// Builds the default HTTP pipeline with all standard policies wired in the correct stage order. + /// + /// + /// The transport that executes HTTP requests. Called after all policies have run. + /// + /// + /// An optional authentication policy inserted at . When + /// no auth policy is added. + /// + /// + /// An optional logger forwarded to . When + /// the policy falls back to NullLogger.Instance. + /// + /// + /// An optional forwarded to and + /// . Defaults to when + /// . + /// + /// A fully assembled ready for use. + public static HttpPipeline CreateDefault( + IAsyncHttpClient transport, + HttpPipelinePolicy? authPolicy = null, + ILogger? logger = null, + TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(transport); + + var builder = new PipelineBuilder() + .Add(new OperationPolicy()) + .Add(new RedirectPolicy()) + .Add(new IdempotencyPolicy()) + .Add(new ClientIdentityPolicy()) + .Add(new RetryPolicy(timeProvider)) + .Add(new SetDatePolicy(timeProvider)) + .Add(new InstrumentationPolicy(logger)); + + if (authPolicy is not null) + { + builder.Add(authPolicy); + } + + return builder.Build(transport); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/DexpacePipelineTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/DexpacePipelineTests.cs new file mode 100644 index 0000000..7f5ede3 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/DexpacePipelineTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +/// +/// Placed in the "Instrumentation" collection so these tests do not run in parallel with +/// InstrumentationPolicyTests — both exercise DexpaceDiagnostics.ActivitySource and +/// concurrent execution causes activity leakage across test instances. +/// +[Collection("Instrumentation")] +public sealed class DexpacePipelineTests +{ + private static Request MakeGetRequest() => Request.Get("https://api.example.com/v1/items"); + + private static DexpaceClientOptions ZeroRetryOptions() => new() + { + Retry = new RetryOptions + { + MaxRetryAttempts = 3, + BaseDelay = TimeSpan.FromMilliseconds(1), + MaxDelay = TimeSpan.FromMilliseconds(5), + } + }; + + // ─── Basic wiring ───────────────────────────────────────────────────────── + + [Fact] + public async Task CreateDefault_ReturnsWorkingPipeline_200() + { + var transport = new ScriptedTransport([new Response(Status.Ok)]); + var pipeline = DexpacePipeline.CreateDefault(transport); + + var response = await pipeline.SendAsync(MakeGetRequest(), ZeroRetryOptions()); + + Assert.Equal(Status.Ok, response.Status); + Assert.Equal(1, transport.CallCount); + } + + [Fact] + public async Task CreateDefault_AuthPolicy_IsIncluded_WhenProvided() + { + var auth = new MarkingPolicy("x-auth-stamped", "true"); + string? authHeaderSeen = null; + var transport = new CapturingTransport(req => + { + authHeaderSeen = req.Headers.Get("x-auth-stamped"); + return new Response(Status.Ok); + }); + + var pipeline = DexpacePipeline.CreateDefault(transport, authPolicy: auth); + await pipeline.SendAsync(MakeGetRequest(), ZeroRetryOptions()); + + Assert.Equal("true", authHeaderSeen); + } + + // ─── Retry wired ───────────────────────────────────────────────────────── + + [Fact] + public async Task CreateDefault_RetryIsWired_503ThenSuccess() + { + var transport = new ScriptedTransport([ + new Response(Status.ServiceUnavailable), + new Response(Status.Ok), + ]); + var pipeline = DexpacePipeline.CreateDefault(transport, timeProvider: new InstantTimeProvider()); + + var response = await pipeline.SendAsync(MakeGetRequest(), ZeroRetryOptions()); + + Assert.Equal(Status.Ok, response.Status); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task CreateDefault_RetryExhausted_ReturnsLastResponse() + { + // MaxRetryAttempts = 3 → 1 initial + 3 retries = 4 calls + var transport = new ScriptedTransport(Enumerable.Repeat(new Response(Status.ServiceUnavailable), 4)); + var pipeline = DexpacePipeline.CreateDefault(transport, timeProvider: new InstantTimeProvider()); + + var response = await pipeline.SendAsync(MakeGetRequest(), ZeroRetryOptions()); + + Assert.Equal(Status.ServiceUnavailable, response.Status); + Assert.Equal(4, transport.CallCount); + } + + // ─── Redirect wired ────────────────────────────────────────────────────── + + [Fact] + public async Task CreateDefault_RedirectIsWired_302ThenSuccess() + { + var redirectHeaders = new Headers.Builder() + .Set("Location", "https://api.example.com/v1/redirected") + .Build(); + + Uri? finalUrl = null; + var transport = new CapturingTransport(req => + { + finalUrl = req.Url; + if (req.Url.AbsolutePath == "/v1/items") + { + return new Response(Status.Found, redirectHeaders); + } + + return new Response(Status.Ok); + }); + + var pipeline = DexpacePipeline.CreateDefault(transport); + var response = await pipeline.SendAsync(MakeGetRequest(), ZeroRetryOptions()); + + Assert.Equal(Status.Ok, response.Status); + Assert.NotNull(finalUrl); + Assert.Equal("/v1/redirected", finalUrl.AbsolutePath); + } + + // ─── Nested helpers ────────────────────────────────────────────────────── + + private sealed class ScriptedTransport : IAsyncHttpClient + { + private readonly List _script; + private int _callCount; + + public ScriptedTransport(IEnumerable script) => _script = [.. script]; + + public int CallCount => _callCount; + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + var index = Interlocked.Increment(ref _callCount) - 1; + if (index >= _script.Count) + { + throw new InvalidOperationException($"Script ran out at call {index + 1}."); + } + + return Task.FromResult(_script[index]); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class CapturingTransport(Func handler) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(handler(request)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + /// A policy that stamps a fixed header — used to verify auth policy injection. + private sealed class MarkingPolicy(string header, string value) : HttpPipelinePolicy + { + // Auth stage so it participates correctly in the default pipeline ordering. + public override PipelineStage Stage => PipelineStage.Auth; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + context.Request = context.Request with + { + Headers = context.Request.Headers.Set(header, value), + }; + await continuation.RunAsync(context).ConfigureAwait(false); + } + } + + private sealed class InstantTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => + new DateTimeOffset(2026, 6, 14, 12, 0, 0, TimeSpan.Zero); + + public override ITimer CreateTimer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period) => + base.CreateTimer(callback, state, TimeSpan.FromMilliseconds(1), period); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs index c2a33bf..cc5fd22 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs @@ -16,6 +16,11 @@ namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; +/// +/// Placed in a dedicated xUnit collection to prevent parallel execution with other test classes +/// that also exercise DexpaceDiagnostics.ActivitySource, avoiding cross-test activity leakage. +/// +[Collection("Instrumentation")] public sealed class InstrumentationPolicyTests : IDisposable { private readonly ActivityListener _listener; From 4bc5723173f9d78626d26c63e63d6c9ce09749a5 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:25:07 +0300 Subject: [PATCH 17/17] fix: inject W3C trace context and enrich InstrumentationPolicy telemetry - Inject traceparent/tracestate request headers when Activity.IdFormat is W3C, so trace context propagates over any transport without relying on transport auto-injection; injection is skipped when there is no active activity. - Add url.scheme activity tag per OTel HTTP semantic conventions. - Record http.client.request.duration with http.request.method and http.response.status_code (or error.type on failure) tags so the histogram can be sliced by method and outcome; tag http.client.active_requests with http.request.method as well. - Restore context.Activity to its previous value in the finally block instead of unconditionally setting it to null, so an outer operation-level activity is not clobbered. - Add 6 tests covering: traceparent injection (W3C), tracestate injection, no-injection without listener, duration histogram method+status tags, url.scheme tag, and previous-activity restore. Co-Authored-By: Claude Opus 4.8 --- .../Policies/InstrumentationPolicy.cs | 140 +++++++++++--- .../Policies/InstrumentationPolicyTests.cs | 182 ++++++++++++++++++ 2 files changed, 291 insertions(+), 31 deletions(-) diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs index 2ad3f00..05cb930 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/InstrumentationPolicy.cs @@ -18,22 +18,32 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// Tracing. A client-kind is started from /// for each attempt. The activity name is the /// HTTP method (low cardinality). OTel HTTP semantic-convention tags are attached: -/// http.request.method, url.full (redacted), server.address, -/// server.port, http.response.status_code, and http.request.resend_count. -/// On exception, error.type is set and the activity status is -/// . When no listener is registered, +/// http.request.method, url.full (redacted), url.scheme, +/// server.address, server.port, http.response.status_code, and +/// http.request.resend_count. On exception, error.type is set and the activity +/// status is . When no listener is registered, /// ActivitySource.StartActivity returns and the hot path /// allocates nothing for tracing. /// /// +/// W3C trace-context propagation. When the started is non-null +/// and its is , the policy +/// stamps traceparent (and, when non-empty, tracestate) onto the request headers +/// before forwarding the call. This ensures trace context propagates over any transport +/// without relying on transport-level auto-injection. +/// +/// /// Metrics. Two instruments are recorded per attempt: /// /// -/// http.client.request.duration in seconds. +/// http.client.request.duration in seconds, tagged +/// with http.request.method and (on completion) http.response.status_code or +/// error.type. /// /// /// http.client.active_requests incremented before -/// the send and decremented after (in a finally block). +/// the send and decremented after (in a finally block), tagged with +/// http.request.method. /// /// /// @@ -94,47 +104,115 @@ public override async ValueTask ProcessAsync(PipelineContext context, PipelineRu { activity.SetTag("http.request.method", method); activity.SetTag("url.full", redactedUrl); + activity.SetTag("url.scheme", request.Url.Scheme); activity.SetTag("server.address", request.Url.Host); activity.SetTag("server.port", request.Url.IsDefaultPort ? -1 : request.Url.Port); activity.SetTag("http.request.resend_count", context.AttemptNumber); - context.Activity = activity; - } - LogSendingRequest(_logger, method, redactedUrl); + // Inject W3C trace context onto the request so any transport carries the span. + if (activity.IdFormat == ActivityIdFormat.W3C && activity.Id is not null) + { + var headers = context.Request.Headers.Set("traceparent", activity.Id); + if (!string.IsNullOrEmpty(activity.TraceStateString)) + { + headers = headers.Set("tracestate", activity.TraceStateString); + } - var sw = Stopwatch.StartNew(); - s_activeRequests.Add(1); + context.Request = context.Request with { Headers = headers }; + } - try - { - await continuation.RunAsync(context).ConfigureAwait(false); + // Capture the previous activity so we can restore it in the finally block. + var previousActivity = context.Activity; + context.Activity = activity; - var statusCode = context.Response?.Status.Code; - if (activity is not null && statusCode.HasValue) + LogSendingRequest(_logger, method, redactedUrl); + + var sw = Stopwatch.StartNew(); + var methodTag = new TagList { { "http.request.method", method } }; + s_activeRequests.Add(1, methodTag); + + try { - activity.SetTag("http.response.status_code", statusCode.Value); + await continuation.RunAsync(context).ConfigureAwait(false); + + var statusCode = context.Response?.Status.Code; + if (statusCode.HasValue) + { + activity.SetTag("http.response.status_code", statusCode.Value); + } + + LogReceivedResponse(_logger, method, statusCode, redactedUrl); + + var durationTags = new TagList + { + { "http.request.method", method }, + { "http.response.status_code", statusCode }, + }; + s_requestDuration.Record(sw.Elapsed.TotalSeconds, durationTags); } - - LogReceivedResponse(_logger, method, statusCode, redactedUrl); - } - catch (Exception ex) - { - if (activity is not null) + catch (Exception ex) { activity.SetTag("error.type", ex.GetType().FullName); activity.SetStatus(ActivityStatusCode.Error, ex.Message); - } - LogRequestFailed(_logger, ex, method, redactedUrl, ex.GetType().Name); + LogRequestFailed(_logger, ex, method, redactedUrl, ex.GetType().Name); + + var durationTags = new TagList + { + { "http.request.method", method }, + { "error.type", ex.GetType().FullName }, + }; + s_requestDuration.Record(sw.Elapsed.TotalSeconds, durationTags); - throw; + throw; + } + finally + { + s_activeRequests.Add(-1, methodTag); + // Restore the activity that was active before we replaced it. + context.Activity = previousActivity; + } } - finally + else { - s_activeRequests.Add(-1); - s_requestDuration.Record(sw.Elapsed.TotalSeconds); - // Null out context.Activity so downstream observers don't access a disposed span. - context.Activity = null; + // No listener: no activity, no trace-context injection, no activity restoration needed. + LogSendingRequest(_logger, method, redactedUrl); + + var sw = Stopwatch.StartNew(); + var methodTag = new TagList { { "http.request.method", method } }; + s_activeRequests.Add(1, methodTag); + + try + { + await continuation.RunAsync(context).ConfigureAwait(false); + + var statusCode = context.Response?.Status.Code; + LogReceivedResponse(_logger, method, statusCode, redactedUrl); + + var durationTags = new TagList + { + { "http.request.method", method }, + { "http.response.status_code", statusCode }, + }; + s_requestDuration.Record(sw.Elapsed.TotalSeconds, durationTags); + } + catch (Exception ex) + { + LogRequestFailed(_logger, ex, method, redactedUrl, ex.GetType().Name); + + var durationTags = new TagList + { + { "http.request.method", method }, + { "error.type", ex.GetType().FullName }, + }; + s_requestDuration.Record(sw.Elapsed.TotalSeconds, durationTags); + + throw; + } + finally + { + s_activeRequests.Add(-1, methodTag); + } } } diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs index cc5fd22..a1f2c43 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs @@ -295,6 +295,170 @@ public async Task ProcessAsync_NullLogger_DoesNotThrow() Assert.Equal(Status.Ok, result.Status); } + // ─── W3C trace-context injection ───────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_W3CActivity_InjectsTraceparentHeader() + { + // Arrange: capture the request the transport receives. + Request? capturedRequest = null; + var transport = new CapturingRequestTransport(req => + { + capturedRequest = req; + return new Response(Status.Ok); + }); + var policy = new InstrumentationPolicy(); + var url = new Uri("https://api.example.com/v1/items"); + + await RunAsync(policy, MakeRequest(url), transport); + + // The listener fixture uses W3C format (the .NET default). + var started = Assert.Single(_activities); + Assert.Equal(ActivityIdFormat.W3C, started.IdFormat); + Assert.NotNull(capturedRequest); + var traceparent = capturedRequest.Headers.Get("traceparent"); + Assert.NotNull(traceparent); + Assert.Equal(started.Id, traceparent); + } + + [Fact] + public async Task ProcessAsync_W3CActivity_InjectsTracestateHeader_WhenNonEmpty() + { + // Arrange: start a parent activity with tracestate so the child inherits it. + using var parentActivity = new Activity("parent"); + parentActivity.TraceStateString = "vendor=value"; + parentActivity.Start(); + + try + { + Request? capturedRequest = null; + var transport = new CapturingRequestTransport(req => + { + capturedRequest = req; + return new Response(Status.Ok); + }); + var policy = new InstrumentationPolicy(); + + await RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport); + + Assert.NotNull(capturedRequest); + var tracestate = capturedRequest.Headers.Get("tracestate"); + Assert.NotNull(tracestate); + Assert.False(string.IsNullOrEmpty(tracestate)); + } + finally + { + parentActivity.Stop(); + } + } + + [Fact] + public async Task ProcessAsync_NoListener_DoesNotInjectTraceparentHeader() + { + // Dispose the listener so StartActivity returns null. + _listener.Dispose(); + + Request? capturedRequest = null; + var transport = new CapturingRequestTransport(req => + { + capturedRequest = req; + return new Response(Status.Ok); + }); + var policy = new InstrumentationPolicy(); + + await RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport); + + Assert.NotNull(capturedRequest); + Assert.False(capturedRequest.Headers.Contains("traceparent"), "traceparent must not be added when there is no activity"); + } + + // ─── Metric dimensions ──────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_DurationHistogram_CarriesMethodAndStatusTags() + { + // Capture tags as an array so we can inspect them outside the callback. + KeyValuePair[]? capturedTags = null; + + using var meterListener = new MeterListener(); + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Dexpace.Sdk" && instrument.Name == "http.client.request.duration") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((_, _, tags, _) => + { + // Materialise the span into an array before it goes out of scope. + capturedTags = tags.ToArray(); + }); + meterListener.Start(); + + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + await RunAsync(policy, MakeRequest(new Uri("https://api.example.com/")), transport); + + meterListener.RecordObservableInstruments(); + Assert.NotNull(capturedTags); + + var tagDict = capturedTags.ToDictionary(kv => kv.Key, kv => kv.Value); + Assert.True(tagDict.ContainsKey("http.request.method"), "Missing http.request.method tag"); + Assert.True(tagDict.ContainsKey("http.response.status_code"), "Missing http.response.status_code tag"); + Assert.Equal("GET", tagDict["http.request.method"]); + } + + // ─── url.scheme tag ─────────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_Activity_HasUrlSchemeTag() + { + var transport = new StaticTransport(new Response(Status.Ok)); + var policy = new InstrumentationPolicy(); + var url = new Uri("https://api.example.com/v1/items"); + + await RunAsync(policy, MakeRequest(url), transport); + + var activity = Assert.Single(_activities); + Assert.Equal("https", activity.GetTagItem("url.scheme")); + } + + // ─── context.Activity restore guard ────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_RestoresPreviousActivity_AfterCompletion() + { + // Arrange: place a sentinel activity in context.Activity before instrumentation runs. + // We do that by wrapping InstrumentationPolicy with an outer policy that sets it first. + Activity? outerActivity = null; + Activity? activityAfterCompletion = null; + + using var sentinel = new Activity("outer-sentinel"); + sentinel.Start(); + outerActivity = sentinel; + + // OuterPolicy sets context.Activity to the sentinel, then calls the rest of the chain. + var outerPolicy = new DelegatePolicy(async (ctx, next) => + { + ctx.Activity = outerActivity; + await next.RunAsync(ctx).ConfigureAwait(false); + activityAfterCompletion = ctx.Activity; + }, stage: (PipelineStage)500); + + var transport = new StaticTransport(new Response(Status.Ok)); + var pipeline = new PipelineBuilder() + .Add(outerPolicy) + .Add(new InstrumentationPolicy()) // Diagnostics = 600, runs after 500 + .Build(transport); + + await pipeline.SendAsync(MakeRequest(new Uri("https://api.example.com/")), DefaultOptions()); + + // After InstrumentationPolicy's finally block, context.Activity should be the sentinel. + Assert.Same(outerActivity, activityAfterCompletion); + + sentinel.Stop(); + } + // ─── Nested helpers ────────────────────────────────────────────────────── private sealed class StaticTransport(Response response) : IAsyncHttpClient @@ -358,6 +522,24 @@ public override ITimer CreateTimer( base.CreateTimer(callback, state, TimeSpan.FromMilliseconds(1), period); } + private sealed class CapturingRequestTransport(Func handler) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(handler(request)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class DelegatePolicy( + Func action, + PipelineStage stage = PipelineStage.PerAttempt) : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) => + action(context, continuation); + } + private sealed class RecordingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = [];