< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.RecoverableAsyncResponseBuilder<T>
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseBuilder.cs
Line coverage
100%
Covered lines: 51
Uncovered lines: 0
Coverable lines: 51
Total lines: 671
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CreateWaiterAsync()100%11100%
SetResumeCallback(...)100%22100%
SetFailureCallback(...)100%22100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberResume(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberResume(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberFailure(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberFailure(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithTimeout(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget()100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.Until(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.Until(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberResume(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberResume(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberFailure(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberFailure(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithTimeout(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget()100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.Until(...)100%11100%
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.Until(...)100%11100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseBuilder.cs

#LineLine coverage
 1using Microsoft.Extensions.Options;
 2using System.Diagnostics;
 3using System.Diagnostics.CodeAnalysis;
 4using System.Linq.Expressions;
 5
 6namespace AsyncResponse;
 7
 8internal abstract class AsyncResponseBuilderBase(
 9    IWorkerTransport? _workerTransport = null,
 10    IAsyncResponseReplyTargetProvider? _replyTargetProvider = null,
 11    AsyncResponseContextPropagation? _propagation = null,
 12    TimeProvider? _timeProvider = null,
 13    IOptions<AsyncResponseOptions>? _options = null)
 14{
 15    protected IAsyncResponseReplyTargetProvider? ReplyTargetProvider => _replyTargetProvider;
 16
 17    // ---------------------------------------------------------------------------------------
 18    // Producer-side size budget — the ingress's MaxInboundMessageChars, enforced before publish.
 19
 20    /// <summary>
 21    /// Worst-case UTF-16 growth of a string under System.Text.Json's default encoder: any code
 22    /// unit can become a six-character <c>\uXXXX</c> escape (non-ASCII, HTML-sensitive and
 23    /// control characters all do), and no character ever shrinks.
 24    /// </summary>
 25    private const int MaxJsonEscapeFactor = 6;
 26
 27    /// <summary>Property names, punctuation and the fixed-width members of one envelope, generously.</summary>
 28    private const int EnvelopeFixedOverhead = 1024;
 29    private const int PerParamOverhead = 64;
 30    private const int PerEntryOverhead = 16;
 31    private const int ScalarOverhead = 64;
 32
 33    /// <summary>
 34    /// Refuses an envelope the consuming ingress would acknowledge without executing. The ingress
 35    /// compares the delivered JSON's UTF-16 length against <see cref="AsyncResponseOptions.MaxInboundMessageChars"/>
 36    /// and drops what exceeds it (an oversized message never gets smaller, so redelivery would
 37    /// hot-loop); without this check the publish succeeded, the caller kept a flow id or a
 38    /// fire-and-forget "success", and the work silently never ran. Measured exactly — the same
 39    /// serialization the transports perform — but only when a cheap upper bound says it might
 40    /// matter, so the hot path of small jobs pays no extra serialization.
 41    /// </summary>
 42    /// <exception cref="WorkerJobTooLargeException">The serialized envelope exceeds the budget.</exception>
 43    protected void ThrowIfOverInboundBudget(WorkerJobEnvelope envelope)
 44    {
 45        if (_options?.Value.MaxInboundMessageChars is not { } limit)
 46            return;
 47
 48        if (TryEstimateUpperBound(envelope, out var upperBound) && upperBound <= limit)
 49            return;
 50
 51        var serialized = AsyncResponseJson.Serialize(envelope);
 52        if (serialized.Length > limit)
 53            throw new WorkerJobTooLargeException(serialized.Length, limit);
 54    }
 55
 56    /// <summary>
 57    /// An upper bound on the envelope's serialized UTF-16 length that never undercounts: every
 58    /// string at its fully-escaped size, every scalar at a fixed allowance, fixed overhead for
 59    /// the property names and punctuation. Returns <c>false</c> when an argument is an arbitrary
 60    /// object whose size cannot be bounded without serializing it.
 61    /// </summary>
 62    internal static bool TryEstimateUpperBound(WorkerJobEnvelope envelope, out long upperBound)
 63    {
 64        long total = EnvelopeFixedOverhead;
 65        total += Escaped(envelope.CorrelationId) + Escaped(envelope.JobId);
 66        total += Escaped(envelope.Call.ServiceInterfaceFullName) + Escaped(envelope.Call.MethodName);
 67
 68        if (envelope.ReplyTarget is { } target)
 69        {
 70            total += Escaped(target.Name) + Escaped(target.Transport) + Escaped(target.Address);
 71            foreach (var (key, value) in target.Properties)
 72                total += Escaped(key) + Escaped(value) + PerEntryOverhead;
 73        }
 74
 75        if (envelope.Context is { } context)
 76        {
 77            foreach (var (key, value) in context)
 78                total += Escaped(key) + Escaped(value) + PerEntryOverhead;
 79        }
 80
 81        foreach (var param in envelope.Call.Params)
 82        {
 83            total += PerParamOverhead;
 84            switch (param.Value)
 85            {
 86                case null:
 87                    break;
 88                case string text:
 89                    total += Escaped(text);
 90                    break;
 91                case bool or byte or sbyte or short or ushort or int or uint or long or ulong
 92                    or float or double or decimal or char or Guid or DateTime or DateTimeOffset or TimeSpan:
 93                    total += ScalarOverhead;
 94                    break;
 95                default:
 96                    upperBound = 0;
 97                    return false;
 98            }
 99        }
 100
 101        upperBound = total;
 102        return true;
 103    }
 104
 105    private static long Escaped(string? value)
 106        => value is null ? 8 : (long)value.Length * MaxJsonEscapeFactor + 2;
 107
 108    /// <summary>Validates the supplied options.</summary>
 109    protected static string ValidateCorrelationId(string correlationId)
 110    {
 111        CorrelationIdGuard.ThrowIfUnusable(correlationId);
 112        return correlationId;
 113    }
 114
 115    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync(ReflectionCallDto, CancellationToken)" />
 116    [RequiresUnreferencedCode("The descriptor names its target service and method as strings, resolved by reflection whe
 117                              "job executes; trimming may have removed them. Use the expression-based EnqueueWorkerAsync
 118                              "overloads, which root the service's public methods automatically.")]
 119    public Task EnqueueWorkerAsync(ReflectionCallDto work, CancellationToken cancellationToken = default)
 120        => EnqueueWorkerCoreAsync(work, TimeSpan.Zero, cancellationToken);
 121
 122    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync(ReflectionCallDto, TimeSpan, CancellationToken)" />
 123    [RequiresUnreferencedCode("The descriptor names its target service and method as strings, resolved by reflection whe
 124                              "job executes; trimming may have removed them. Use the expression-based EnqueueWorkerAsync
 125                              "overloads, which root the service's public methods automatically.")]
 126    public Task EnqueueWorkerAsync(ReflectionCallDto work, TimeSpan delay, CancellationToken cancellationToken = default
 127        => EnqueueWorkerCoreAsync(work, delay, cancellationToken);
 128
 129    // Shared by the annotation-free expression overloads (whose TService is rooted via
 130    // DynamicallyAccessedMembers) and the RequiresUnreferencedCode DTO overloads above.
 131    private async Task EnqueueWorkerCoreAsync(ReflectionCallDto work, TimeSpan delay, CancellationToken cancellationToke
 132    {
 133        ArgumentNullException.ThrowIfNull(work);
 134        cancellationToken.ThrowIfCancellationRequested();
 135
 136        using var activity = AsyncResponseDiagnostics.StartActivity(
 137            "asyncresponse.enqueue_worker",
 138            ActivityKind.Producer,
 139            AsyncResponseContext.CorrelationId);
 140        AsyncResponseDiagnostics.SetWorker(activity, work);
 141        AsyncResponseDiagnostics.SetReplyTarget(activity, AsyncResponseContext.ReplyTarget);
 142
 143        try
 144        {
 145            var transport = _workerTransport ?? throw new InvalidOperationException(
 146                "No IWorkerTransport is registered. Call .WithInMemoryTransport() for in-process execution, " +
 147                ".WithGooglePubSubTransport(...) for Google Pub/Sub, " +
 148                "or install another full AsyncResponse transport package.");
 149
 150            // The worker publish path was the only public entry point that did not apply the
 151            // portable-id contract. A non-blank ambient id outside it published cleanly and was
 152            // then drop-ACKed by the consumer (WorkerJobExecutor rejects it and returns, by
 153            // design — throwing there would poison the queue), so the job silently never ran, the
 154            // producer saw success, and any waiter burned its full timeout. Throw here instead,
 155            // where the caller can still fix it: "the library throws it back at every public entry
 156            // point" is the guard's own stated contract. A blank id is left alone — that is a
 157            // fire-and-forget job with no response to publish.
 158            var ambientCorrelationId = AsyncResponseContext.CorrelationId;
 159            if (!string.IsNullOrWhiteSpace(ambientCorrelationId))
 160                CorrelationIdGuard.ThrowIfUnusable(ambientCorrelationId);
 161
 162            var envelope = new WorkerJobEnvelope
 163            {
 164                Call = work,
 165                CorrelationId = ambientCorrelationId,
 166                ReplyTarget = AsyncResponseContext.ReplyTarget,
 167                Context = _propagation?.Capture(),
 168                // Minted once, here, and carried by every later copy of this job (a broker
 169                // redelivery, a NotBeforeUtc re-publish hop): it is what lets a consumer tell the
 170                // broker redelivering a job whose handler is still running apart from a second,
 171                // independently enqueued job. Two enqueues never share one.
 172                JobId = Guid.NewGuid().ToString("N")
 173            };
 174
 175            if (delay <= TimeSpan.Zero)
 176            {
 177                ThrowIfOverInboundBudget(envelope);
 178                await transport.PublishAsync(envelope, cancellationToken).ConfigureAwait(false);
 179                return;
 180            }
 181
 182            // Bounded like every persisted-deadline knob: the absolute due-time stamp below is
 183            // "now + delay", and an unbounded delay would pass here and overflow at the stamp.
 184            if (delay > AsyncResponseChannelOptions.MaxPersistenceTtl)
 185            {
 186                throw new ArgumentOutOfRangeException(
 187                    nameof(delay),
 188                    delay,
 189                    $"Worker-job delay must be at most {AsyncResponseChannelOptions.MaxPersistenceTtl.TotalDays:0} days.
 190            }
 191
 192            // MaxPublishDelay <= zero: the type implements the capability but the current
 193            // configuration cannot honor it (an SQS FIFO worker queue) — same guidance either way.
 194            if (transport is not IDelayedWorkerTransport delayedTransport
 195                || delayedTransport.MaxPublishDelay <= TimeSpan.Zero)
 196            {
 197                throw new InvalidOperationException(
 198                    $"The registered worker transport ({transport.GetType().Name}) does not support native delayed deliv
 199                    $"({nameof(IDelayedWorkerTransport)}) in its current configuration, so EnqueueWorkerAsync with a del
 200                    "Register a delayed-capable transport (in-memory, Azure Service Bus, SQS, PostgreSQL, SQL Server, Mo
 201                    "or — inside a durable flow — use IDurableFlowContext.DelayAsync followed by an immediate enqueue, "
 202                    "which works on every transport.");
 203            }
 204
 205            activity?.SetTag("asyncresponse.worker.delay_seconds", delay.TotalSeconds);
 206
 207            // The absolute due time rides the envelope; the per-hop delay is clamped to the
 208            // transport's cap. An early delivery (a capped hop, broker imprecision) is re-published
 209            // by the worker-job executor for the remainder, so the due time holds end to end.
 210            envelope.NotBeforeUtc = (_timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime.Add(delay);
 211            var hop = delay <= delayedTransport.MaxPublishDelay ? delay : delayedTransport.MaxPublishDelay;
 212            // After the due-time stamp: the check measures the envelope exactly as it is published.
 213            ThrowIfOverInboundBudget(envelope);
 214            await delayedTransport.PublishAsync(envelope, hop, cancellationToken).ConfigureAwait(false);
 215        }
 216        catch (Exception ex)
 217        {
 218            AsyncResponseDiagnostics.SetError(activity, ex);
 219            throw;
 220        }
 221    }
 222
 223    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync{TService}(Expression{Action{TService}}, CancellationT
 224    public Task EnqueueWorkerAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TService>(
 225    {
 226        cancellationToken.ThrowIfCancellationRequested();
 227        return EnqueueWorkerCoreAsync(CallbackExpressionConverter.ToReflectionCall(work), TimeSpan.Zero, cancellationTok
 228    }
 229
 230    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync{TService}(Expression{Func{TService, Task}}, Cancellat
 231    public Task EnqueueWorkerAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TService>(
 232    {
 233        cancellationToken.ThrowIfCancellationRequested();
 234        return EnqueueWorkerCoreAsync(CallbackExpressionConverter.ToReflectionCall(work), TimeSpan.Zero, cancellationTok
 235    }
 236
 237    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync{TService}(Expression{Func{TService, ValueTask}}, Canc
 238    public Task EnqueueWorkerAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TService>(
 239    {
 240        cancellationToken.ThrowIfCancellationRequested();
 241        return EnqueueWorkerCoreAsync(CallbackExpressionConverter.ToReflectionCall(work), TimeSpan.Zero, cancellationTok
 242    }
 243
 244    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync{TService}(Expression{Action{TService}}, TimeSpan, Can
 245    public Task EnqueueWorkerAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TService>(
 246    {
 247        cancellationToken.ThrowIfCancellationRequested();
 248        return EnqueueWorkerCoreAsync(CallbackExpressionConverter.ToReflectionCall(work), delay, cancellationToken);
 249    }
 250
 251    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync{TService}(Expression{Func{TService, Task}}, TimeSpan,
 252    public Task EnqueueWorkerAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TService>(
 253    {
 254        cancellationToken.ThrowIfCancellationRequested();
 255        return EnqueueWorkerCoreAsync(CallbackExpressionConverter.ToReflectionCall(work), delay, cancellationToken);
 256    }
 257
 258    /// <inheritdoc cref="IAsyncResponseBuilder.EnqueueWorkerAsync{TService}(Expression{Func{TService, ValueTask}}, Time
 259    public Task EnqueueWorkerAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TService>(
 260    {
 261        cancellationToken.ThrowIfCancellationRequested();
 262        return EnqueueWorkerCoreAsync(CallbackExpressionConverter.ToReflectionCall(work), delay, cancellationToken);
 263    }
 264}
 265
 266/// <inheritdoc cref="IAsyncResponseBuilder"/>
 267internal sealed class AsyncResponseBuilder(
 268    IAsyncResponseSubscriber _subscriber,
 269    IWorkerTransport? workerTransport = null,
 270    IAsyncResponseReplyTargetProvider? replyTargetProvider = null,
 271    AsyncResponseContextPropagation? propagation = null,
 272    TimeProvider? timeProvider = null,
 273    IOptions<AsyncResponseOptions>? options = null)
 274    : AsyncResponseBuilderBase(workerTransport, replyTargetProvider, propagation, timeProvider, options),
 275        IAsyncResponseBuilder
 276{
 277    /// <inheritdoc />
 278    public IAsyncResponseAttachedBuilder<T> For<T>(string correlationId) where T : IAsyncResponsePayload
 279        => new AsyncResponseBuilder<T>(_subscriber, ReplyTargetProvider, ValidateCorrelationId(correlationId));
 280
 281    /// <inheritdoc />
 282    public IAsyncResponseTriggeredBuilder<T> For<T>() where T : IAsyncResponsePayload
 283        => new AsyncResponseBuilder<T>(_subscriber, ReplyTargetProvider, AsyncResponseContext.CreateCorrelationId());
 284}
 285
 286/// <inheritdoc cref="IRecoverableAsyncResponseBuilder"/>
 287internal sealed class RecoverableAsyncResponseBuilder(
 288    IRecoverableAsyncResponseSubscriber _subscriber,
 289    IWorkerTransport? workerTransport = null,
 290    IAsyncResponseReplyTargetProvider? replyTargetProvider = null,
 291    AsyncResponseContextPropagation? propagation = null,
 292    TimeProvider? timeProvider = null,
 293    IOptions<AsyncResponseOptions>? options = null)
 294    : AsyncResponseBuilderBase(workerTransport, replyTargetProvider, propagation, timeProvider, options),
 295        IRecoverableAsyncResponseBuilder
 296{
 297    /// <inheritdoc />
 298    public IRecoverableAsyncResponseAttachedBuilder<T> For<T>(string correlationId) where T : IAsyncResponsePayload
 299        => new RecoverableAsyncResponseBuilder<T>(_subscriber, ReplyTargetProvider, ValidateCorrelationId(correlationId)
 300
 301    /// <inheritdoc />
 302    public IRecoverableAsyncResponseTriggeredBuilder<T> For<T>() where T : IAsyncResponsePayload
 303        => new RecoverableAsyncResponseBuilder<T>(_subscriber, ReplyTargetProvider, AsyncResponseContext.CreateCorrelati
 304
 305    IAsyncResponseAttachedBuilder<T> IAsyncResponseBuilder.For<T>(string correlationId)
 306        => For<T>(correlationId);
 307
 308    IAsyncResponseTriggeredBuilder<T> IAsyncResponseBuilder.For<T>()
 309        => For<T>();
 310}
 311
 312/// <inheritdoc cref="IAsyncResponseAttachedBuilder{T}" />
 313internal class AsyncResponseBuilder<T> : IAsyncResponseAttachedBuilder<T>, IAsyncResponseTriggeredBuilder<T>
 314    where T : IAsyncResponsePayload
 315{
 316    private readonly IAsyncResponseSubscriber _subscriber;
 317    private readonly IAsyncResponseReplyTargetProvider? _replyTargetProvider;
 318    protected readonly string _correlationId;
 319    protected Func<T, ValueTask<bool>>? _completionPredicate;
 320    protected TimeSpan? _timeout;
 321    private bool _useReplyTarget;
 322    private string? _replyTargetName;
 323    private AsyncResponseReplyTarget? _replyTarget;
 324    private int _consumed;
 325
 326    internal AsyncResponseBuilder(
 327        IAsyncResponseSubscriber subscriber,
 328        IAsyncResponseReplyTargetProvider? replyTargetProvider,
 329        string correlationId)
 330    {
 331        _subscriber = subscriber;
 332        _replyTargetProvider = replyTargetProvider;
 333        _correlationId = correlationId;
 334    }
 335
 336    /// <inheritdoc />
 337    public IAsyncResponseAttachedBuilder<T> WithTimeout(TimeSpan timeout)
 338    {
 339        if (timeout <= TimeSpan.Zero)
 340            throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero.");
 341
 342        _timeout = timeout;
 343        return this;
 344    }
 345
 346    /// <inheritdoc />
 347    public IAsyncResponseAttachedBuilder<T> WithReplyTarget()
 348    {
 349        _useReplyTarget = true;
 350        _replyTargetName = null;
 351        _replyTarget = null;
 352        return this;
 353    }
 354
 355    /// <inheritdoc />
 356    public IAsyncResponseAttachedBuilder<T> WithReplyTarget(string name)
 357    {
 358        _useReplyTarget = true;
 359        _replyTargetName = !string.IsNullOrWhiteSpace(name)
 360            ? name
 361            : throw new ArgumentException("Reply target name cannot be null or whitespace.", nameof(name));
 362        _replyTarget = null;
 363        return this;
 364    }
 365
 366    /// <inheritdoc />
 367    public IAsyncResponseAttachedBuilder<T> WithReplyTarget(AsyncResponseReplyTarget replyTarget)
 368    {
 369        ArgumentNullException.ThrowIfNull(replyTarget);
 370        ValidateReplyTarget(replyTarget);
 371
 372        _useReplyTarget = true;
 373        _replyTargetName = null;
 374        _replyTarget = replyTarget;
 375        return this;
 376    }
 377
 378    /// <inheritdoc />
 379    public IAsyncResponseAttachedBuilder<T> Until(Func<T, bool> predicate)
 380    {
 381        _completionPredicate = predicate != null
 382            ? payload => new ValueTask<bool>(predicate(payload))
 383            : throw new ArgumentNullException(nameof(predicate));
 384        return this;
 385    }
 386
 387    /// <inheritdoc />
 388    public IAsyncResponseAttachedBuilder<T> Until(Func<T, Task<bool>> predicate)
 389    {
 390        _completionPredicate = predicate != null
 391            ? payload => new ValueTask<bool>(predicate(payload))
 392            : throw new ArgumentNullException(nameof(predicate));
 393        return this;
 394    }
 395
 396    /// <inheritdoc cref="IAsyncResponseAttachedBuilder{T}.WaitAsync" />
 397    public Task<T> WaitAsync()
 398        => WaitCoreAsync((Func<AsyncResponseRequestContext, Task>?)null);
 399
 400    /// <inheritdoc cref="IAsyncResponseTriggeredBuilder{T}.WaitAsync(Func{AsyncResponseRequestContext, Task})" />
 401    public Task<T> WaitAsync(Func<AsyncResponseRequestContext, Task> trigger)
 402    {
 403        ArgumentNullException.ThrowIfNull(trigger);
 404        return WaitCoreAsync(trigger);
 405    }
 406
 407    /// <summary>Creates the requested resource.</summary>
 408    protected virtual Task<IAsyncResponseWaiter<T>> CreateWaiterAsync()
 409        => _subscriber.CreateResponseWaiter<T>(_correlationId, _completionPredicate, _timeout);
 410
 411    private async Task<T> WaitCoreAsync(Func<AsyncResponseRequestContext, Task>? trigger)
 412    {
 413        // Builders are single-use: reuse would re-register the SAME correlation id and re-fire
 414        // the trigger — exactly the double-send the attached/triggered split exists to prevent.
 415        if (Interlocked.Exchange(ref _consumed, 1) != 0)
 416        {
 417            throw new InvalidOperationException(
 418                "This async-response builder has already been awaited. Builders are single-use — " +
 419                "call For<T>() again so every wait gets its own correlation id and registration.");
 420        }
 421
 422        await using var waiter = await CreateWaiterAsync().ConfigureAwait(false);
 423        var replyTarget = ResolveReplyTarget();
 424        var requestContext = new AsyncResponseRequestContext(_correlationId, replyTarget);
 425
 426        // Subscribe-before-send by construction: the trigger runs only once the subscription and
 427        // the recovery state exist, so the first response can never race the registration. A
 428        // failing trigger means the operation never started — the waiter (and with it the
 429        // recovery state) is torn down by the await-using disposal as the exception propagates.
 430        if (trigger != null)
 431        {
 432            using var contextScope = AsyncResponseContext.PushContext(_correlationId, replyTarget);
 433            await trigger(requestContext).ConfigureAwait(false);
 434        }
 435
 436        return await waiter.ResponseTask.ConfigureAwait(false);
 437    }
 438
 439    private AsyncResponseReplyTarget? ResolveReplyTarget()
 440    {
 441        if (!_useReplyTarget)
 442            return null;
 443
 444        var replyTarget = _replyTarget
 445            ?? (_replyTargetProvider ?? throw new InvalidOperationException(
 446                "No async-response reply target provider is registered. Register a transport package " +
 447                "that provides reply targets, such as .WithGooglePubSubTransport(...), or pass an " +
 448                "explicit AsyncResponseReplyTarget to .WithReplyTarget(...)."))
 449            .GetReplyTarget(_replyTargetName);
 450
 451        ValidateReplyTarget(replyTarget);
 452        return replyTarget;
 453    }
 454
 455    private static void ValidateReplyTarget(AsyncResponseReplyTarget replyTarget)
 456    {
 457        ArgumentException.ThrowIfNullOrWhiteSpace(replyTarget.Name);
 458        ArgumentException.ThrowIfNullOrWhiteSpace(replyTarget.Transport);
 459        ArgumentException.ThrowIfNullOrWhiteSpace(replyTarget.Address);
 460    }
 461
 462    // -----------------------------------------------------------------------------------------
 463    // IAsyncResponseTriggeredBuilder<T> — the builder handed out by For<T>() (generated
 464    // correlation id). Same shared state and behavior; only the static return type differs, so
 465    // the trigger-required WaitAsync terminal is preserved through the fluent chain. The public
 466    // WaitAsync(Func<AsyncResponseRequestContext, Task>) overload above satisfies its terminal.
 467
 468    IAsyncResponseTriggeredBuilder<T> IAsyncResponseTriggeredBuilder<T>.WithTimeout(TimeSpan timeout)
 469    {
 470        WithTimeout(timeout);
 471        return this;
 472    }
 473
 474    IAsyncResponseTriggeredBuilder<T> IAsyncResponseTriggeredBuilder<T>.WithReplyTarget()
 475    {
 476        WithReplyTarget();
 477        return this;
 478    }
 479
 480    IAsyncResponseTriggeredBuilder<T> IAsyncResponseTriggeredBuilder<T>.WithReplyTarget(string name)
 481    {
 482        WithReplyTarget(name);
 483        return this;
 484    }
 485
 486    IAsyncResponseTriggeredBuilder<T> IAsyncResponseTriggeredBuilder<T>.WithReplyTarget(AsyncResponseReplyTarget replyTa
 487    {
 488        WithReplyTarget(replyTarget);
 489        return this;
 490    }
 491
 492    IAsyncResponseTriggeredBuilder<T> IAsyncResponseTriggeredBuilder<T>.Until(Func<T, bool> predicate)
 493    {
 494        Until(predicate);
 495        return this;
 496    }
 497
 498    IAsyncResponseTriggeredBuilder<T> IAsyncResponseTriggeredBuilder<T>.Until(Func<T, Task<bool>> predicate)
 499    {
 500        Until(predicate);
 501        return this;
 502    }
 503}
 504
 505/// <inheritdoc cref="IRecoverableAsyncResponseAttachedBuilder{T}" />
 506internal sealed class RecoverableAsyncResponseBuilder<T> :
 507    AsyncResponseBuilder<T>,
 508    IRecoverableAsyncResponseAttachedBuilder<T>,
 509    IRecoverableAsyncResponseTriggeredBuilder<T>
 510    where T : IAsyncResponsePayload
 511{
 512    private readonly IRecoverableAsyncResponseSubscriber _subscriber;
 513    private ReflectionCallDto? _resumeCallback;
 514    private ReflectionCallDto? _failureCallback;
 515
 516    internal RecoverableAsyncResponseBuilder(
 517        IRecoverableAsyncResponseSubscriber subscriber,
 518        IAsyncResponseReplyTargetProvider? replyTargetProvider,
 519        string correlationId)
 2079520        : base(subscriber, replyTargetProvider, correlationId)
 521    {
 2079522        _subscriber = subscriber;
 2079523    }
 524
 525    /// <summary>Creates the requested resource.</summary>
 526    protected override Task<IAsyncResponseWaiter<T>> CreateWaiterAsync()
 2073527        => _subscriber.CreateRecoverableResponseWaiter<T>(
 2073528            _correlationId,
 2073529            _resumeCallback,
 2073530            _failureCallback,
 2073531            _completionPredicate,
 2073532            _timeout);
 533
 534    private void SetResumeCallback(ReflectionCallDto callback)
 12535        => _resumeCallback = callback ?? throw new ArgumentNullException(nameof(callback));
 536
 537    private void SetFailureCallback(ReflectionCallDto callback)
 12538        => _failureCallback = callback ?? throw new ArgumentNullException(nameof(callback));
 539
 540    [RequiresUnreferencedCode("The callback names its target service and method as strings, resolved by reflection when 
 541                              "subscriber loss; trimming may have removed them. Use the expression-based overload, which
 542                              "public methods automatically.")]
 543    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberResume(Refle
 544    {
 4545        SetResumeCallback(callback);
 2546        return this;
 547    }
 548
 549    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberResume<[Dyna
 550    {
 2551        SetResumeCallback(CallbackExpressionConverter.ToReflectionCall(callback));
 2552        return this;
 553    }
 554
 555    [RequiresUnreferencedCode("The callback names its target service and method as strings, resolved by reflection when 
 556                              "subscriber loss; trimming may have removed them. Use the expression-based overload, which
 557                              "public methods automatically.")]
 558    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberFailure(Refl
 559    {
 4560        SetFailureCallback(callback);
 2561        return this;
 562    }
 563
 564    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberFailure<[Dyn
 565    {
 2566        SetFailureCallback(CallbackExpressionConverter.ToReflectionCall(callback));
 2567        return this;
 568    }
 569
 570    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.WithTimeout(TimeSpan timeout
 571    {
 2572        WithTimeout(timeout);
 2573        return this;
 574    }
 575
 576    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget()
 577    {
 2578        WithReplyTarget();
 2579        return this;
 580    }
 581
 582    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget(string name)
 583    {
 2584        WithReplyTarget(name);
 2585        return this;
 586    }
 587
 588    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget(AsyncRespons
 589    {
 2590        WithReplyTarget(replyTarget);
 2591        return this;
 592    }
 593
 594    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.Until(Func<T, bool> predicat
 595    {
 2596        Until(predicate);
 2597        return this;
 598    }
 599
 600    IRecoverableAsyncResponseAttachedBuilder<T> IRecoverableAsyncResponseAttachedBuilder<T>.Until(Func<T, Task<bool>> pr
 601    {
 2602        Until(predicate);
 2603        return this;
 604    }
 605
 606    [RequiresUnreferencedCode("The callback names its target service and method as strings, resolved by reflection when 
 607                              "subscriber loss; trimming may have removed them. Use the expression-based overload, which
 608                              "public methods automatically.")]
 609    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberResume(Ref
 610    {
 4611        SetResumeCallback(callback);
 2612        return this;
 613    }
 614
 615    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberResume<[Dy
 616    {
 4617        SetResumeCallback(CallbackExpressionConverter.ToReflectionCall(callback));
 2618        return this;
 619    }
 620
 621    [RequiresUnreferencedCode("The callback names its target service and method as strings, resolved by reflection when 
 622                              "subscriber loss; trimming may have removed them. Use the expression-based overload, which
 623                              "public methods automatically.")]
 624    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberFailure(Re
 625    {
 4626        SetFailureCallback(callback);
 2627        return this;
 628    }
 629
 630    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberFailure<[D
 631    {
 2632        SetFailureCallback(CallbackExpressionConverter.ToReflectionCall(callback));
 2633        return this;
 634    }
 635
 636    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.WithTimeout(TimeSpan timeo
 637    {
 4638        WithTimeout(timeout);
 4639        return this;
 640    }
 641
 642    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget()
 643    {
 2644        WithReplyTarget();
 2645        return this;
 646    }
 647
 648    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget(string nam
 649    {
 2650        WithReplyTarget(name);
 2651        return this;
 652    }
 653
 654    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget(AsyncRespo
 655    {
 2656        WithReplyTarget(replyTarget);
 2657        return this;
 658    }
 659
 660    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.Until(Func<T, bool> predic
 661    {
 4662        Until(predicate);
 4663        return this;
 664    }
 665
 666    IRecoverableAsyncResponseTriggeredBuilder<T> IRecoverableAsyncResponseTriggeredBuilder<T>.Until(Func<T, Task<bool>> 
 667    {
 2668        Until(predicate);
 2669        return this;
 670    }
 671}

Methods/Properties

.ctor(AsyncResponse.IRecoverableAsyncResponseSubscriber,AsyncResponse.IAsyncResponseReplyTargetProvider,System.String)
CreateWaiterAsync()
SetResumeCallback(AsyncResponse.ReflectionCallDto)
SetFailureCallback(AsyncResponse.ReflectionCallDto)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberResume(AsyncResponse.ReflectionCallDto)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberResume(System.Linq.Expressions.Expression`1<System.Func`2<TService,System.Threading.Tasks.Task>>)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberFailure(AsyncResponse.ReflectionCallDto)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.OnLostSubscriberFailure(System.Linq.Expressions.Expression`1<System.Func`2<TService,System.Threading.Tasks.Task>>)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithTimeout(System.TimeSpan)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget()
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget(System.String)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.WithReplyTarget(AsyncResponse.AsyncResponseReplyTarget)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.Until(System.Func`2<T,System.Boolean>)
AsyncResponse.IRecoverableAsyncResponseAttachedBuilder<T>.Until(System.Func`2<T,System.Threading.Tasks.Task`1<System.Boolean>>)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberResume(AsyncResponse.ReflectionCallDto)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberResume(System.Linq.Expressions.Expression`1<System.Func`2<TService,System.Threading.Tasks.Task>>)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberFailure(AsyncResponse.ReflectionCallDto)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.OnLostSubscriberFailure(System.Linq.Expressions.Expression`1<System.Func`2<TService,System.Threading.Tasks.Task>>)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithTimeout(System.TimeSpan)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget()
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget(System.String)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.WithReplyTarget(AsyncResponse.AsyncResponseReplyTarget)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.Until(System.Func`2<T,System.Boolean>)
AsyncResponse.IRecoverableAsyncResponseTriggeredBuilder<T>.Until(System.Func`2<T,System.Threading.Tasks.Task`1<System.Boolean>>)