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

Information
Class: AsyncResponse.Transports.NATS.NatsTransportOptionsValidator
Assembly: AsyncResponse.Transports.NATS
File(s): /_/src/Transports/AsyncResponse.Transports.NATS/NatsTransportOptionsValidator.cs
Line coverage
98%
Covered lines: 101
Uncovered lines: 2
Coverable lines: 103
Total lines: 208
Line coverage: 98%
Branch coverage
93%
Covered branches: 45
Total branches: 48
Branch coverage: 93.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Required(...)100%22100%
PositiveOrNull(...)100%44100%
ValidateSubjectToken(...)100%1010100%
EnsureNameLength(...)100%44100%
EnsureDistinct(...)100%22100%
ValidateCommon(...)75%121296.15%
ValidateSubscriber(...)100%22100%
ValidateSubscriber(...)100%1212100%

File(s)

/_/src/Transports/AsyncResponse.Transports.NATS/NatsTransportOptionsValidator.cs

#LineLine coverage
 1namespace AsyncResponse.Transports.NATS;
 2
 3internal static class NatsTransportOptionsValidator
 4{
 5    /// <summary>Validates the supplied options.</summary>
 6    public static string Required(string? value, string name)
 40187        => !string.IsNullOrWhiteSpace(value)
 40188            ? value
 40189            : throw new InvalidOperationException($"{nameof(NatsAsyncResponseTransportOptions)}.{name} must be configure
 10
 11    /// <summary>Validates the supplied options.</summary>
 12    public static void PositiveOrNull(long? value, string name)
 13    {
 130614        if (value is <= 0)
 215            throw new InvalidOperationException($"{nameof(NatsAsyncResponseTransportOptions)}.{name} must be positive wh
 130416    }
 17
 18    private static void ValidateSubjectToken(string? value, string name)
 19    {
 273620        if (string.IsNullOrWhiteSpace(value))
 201021            return;
 22
 72623        if (value.IndexOfAny([' ', '\t', '*', '>', '\r', '\n']) >= 0)
 1024            throw new InvalidOperationException(
 1025                $"{nameof(NatsAsyncResponseTransportOptions)}.{name} '{value}' must not contain whitespace or the NATS w
 26
 27        // Dots namespace a subject, but a leading, trailing or doubled '.' yields an EMPTY token
 28        // — a subject nats-server rejects with a non-fatal -ERR that NATS.Net never surfaces, so
 29        // the failure showed up as silent NoResponders at runtime rather than here (channel-options
 30        // parity).
 71631        if (value.StartsWith('.') || value.EndsWith('.') || value.Contains("..", StringComparison.Ordinal))
 1232            throw new InvalidOperationException(
 1233                $"{nameof(NatsAsyncResponseTransportOptions)}.{name} '{value}' must not begin or end with '.' or contain
 70434    }
 35
 36    /// <summary>
 37    /// nats-server caps JetStream stream/consumer names at 255 characters (its subject-length
 38    /// default is far larger, but names derived from subjects share the cap). A longer value fails
 39    /// stream/consumer creation at first use — deep inside the subscriber retry loop as an opaque
 40    /// broker error retried forever — and derived stream names size a stack buffer from the
 41    /// subject, so the bound is enforced here as a named startup error.
 42    /// </summary>
 43    private const int NameLengthCap = 255;
 44
 45    private static void EnsureNameLength(string? value, string name)
 46    {
 1004247        if (value is not null && value.Length > NameLengthCap)
 1048            throw new InvalidOperationException(
 1049                $"{nameof(NatsAsyncResponseTransportOptions)}.{name} resolves to {value.Length} characters; " +
 1050                $"NATS limits subjects and JetStream stream/consumer names to {NameLengthCap} characters, " +
 1051                "so longer values fail stream/consumer creation at first use instead of at startup.");
 1003252    }
 53
 54    private static void EnsureDistinct(string left, string right, string kind, string leftName, string rightName)
 55    {
 397456        if (StringComparer.Ordinal.Equals(left, right))
 657            throw new InvalidOperationException(
 658                $"{nameof(NatsAsyncResponseTransportOptions)}.{leftName} and " +
 659                $"{nameof(NatsAsyncResponseTransportOptions)}.{rightName} must resolve to distinct {kind}s " +
 660                $"(both resolve to '{left}') so worker, response, and dead-letter traffic do not consume each other's me
 396861    }
 62
 63    /// <summary>Validates the supplied options.</summary>
 64    public static void ValidateCommon(NatsAsyncResponseTransportOptions options)
 65    {
 70066        _ = Required(options.SubjectPrefix, nameof(options.SubjectPrefix));
 70067        _ = Required(options.WorkerConsumer, nameof(options.WorkerConsumer));
 69868        _ = Required(options.ResponseConsumer, nameof(options.ResponseConsumer));
 69869        _ = Required(options.CorrelationIdHeader, nameof(options.CorrelationIdHeader));
 69870        _ = Required(options.DefaultReplyTargetName, nameof(options.DefaultReplyTargetName));
 71
 72        // A subject prefix becomes leading tokens of every transport subject; it must not contain
 73        // whitespace, the NATS subject wildcards, or an empty token.
 69874        ValidateSubjectToken(options.SubjectPrefix, nameof(options.SubjectPrefix));
 75
 76        // An explicitly configured subject must satisfy the same token rules as the prefix-derived
 77        // defaults: whitespace or a wildcard fails stream/consumer creation at first use — deep
 78        // inside the subscriber retry loop as an opaque broker error retried forever — instead of
 79        // as a named startup error here.
 68680        ValidateSubjectToken(options.WorkerSubject, nameof(options.WorkerSubject));
 67681        ValidateSubjectToken(options.ResponseSubject, nameof(options.ResponseSubject));
 67682        ValidateSubjectToken(options.DeadLetterSubject, nameof(options.DeadLetterSubject));
 83
 84        // Length caps must run BEFORE the schema below resolves anything: stream defaulting sizes
 85        // a stack buffer from the subject, so the raw inputs are bounded before code derives from
 86        // them.
 67687        EnsureNameLength(options.SubjectPrefix, nameof(options.SubjectPrefix));
 67488        EnsureNameLength(options.WorkerSubject, nameof(options.WorkerSubject));
 67289        EnsureNameLength(options.ResponseSubject, nameof(options.ResponseSubject));
 67290        EnsureNameLength(options.DeadLetterSubject, nameof(options.DeadLetterSubject));
 67291        EnsureNameLength(options.WorkerStream, nameof(options.WorkerStream));
 67092        EnsureNameLength(options.ResponseStream, nameof(options.ResponseStream));
 67093        EnsureNameLength(options.DeadLetterStream, nameof(options.DeadLetterStream));
 67094        EnsureNameLength(options.WorkerConsumer, nameof(options.WorkerConsumer));
 66895        EnsureNameLength(options.ResponseConsumer, nameof(options.ResponseConsumer));
 96
 97        // Worker, response, and dead-letter traffic must never share a subject or a stream: the
 98        // durable consumers are unfiltered, so a shared stream feeds every role every message (and
 99        // a dead-letter republish landing back in the worker stream loops poison forever). Compare
 100        // the RESOLVED names, as the Redis sibling does: stream defaulting sanitizes every
 101        // non-[A-Za-z0-9-_] char to '_', so even distinct subjects ('a.b' vs 'a_b') can collide on
 102        // one stream — which EnsureStreamAsync would then silently repoint to whichever role ran
 103        // last.
 668104        var schema = new NatsTransportSubjectSchema(options);
 105
 106        // Re-check the RESOLVED names: a prefix inside the cap can still derive an over-cap
 107        // subject/stream once the role suffix is appended.
 668108        EnsureNameLength(schema.WorkerSubject, nameof(options.WorkerSubject));
 666109        EnsureNameLength(schema.ResponseSubject, nameof(options.ResponseSubject));
 666110        EnsureNameLength(schema.DeadLetterSubject, nameof(options.DeadLetterSubject));
 666111        EnsureNameLength(schema.WorkerStream, nameof(options.WorkerStream));
 666112        EnsureNameLength(schema.ResponseStream, nameof(options.ResponseStream));
 666113        EnsureNameLength(schema.DeadLetterStream, nameof(options.DeadLetterStream));
 114
 666115        EnsureDistinct(schema.WorkerSubject, schema.ResponseSubject, "subject", nameof(options.WorkerSubject), nameof(op
 664116        EnsureDistinct(schema.WorkerSubject, schema.DeadLetterSubject, "subject", nameof(options.WorkerSubject), nameof(
 662117        EnsureDistinct(schema.ResponseSubject, schema.DeadLetterSubject, "subject", nameof(options.ResponseSubject), nam
 662118        EnsureDistinct(schema.WorkerStream, schema.ResponseStream, "stream", nameof(options.WorkerStream), nameof(option
 660119        EnsureDistinct(schema.WorkerStream, schema.DeadLetterStream, "stream", nameof(options.WorkerStream), nameof(opti
 660120        EnsureDistinct(schema.ResponseStream, schema.DeadLetterStream, "stream", nameof(options.ResponseStream), nameof(
 121
 122        // AckWait is a server-side JetStream consumer deadline carried as nanoseconds on the wire,
 123        // but it ALSO arms the in-process ack-extension heartbeat's Task.Delay at one third of its
 124        // value, so its real sink is the timer ceiling — under the persistence bound a legal
 125        // multi-month value passed validation and then killed every batch with
 126        // ArgumentOutOfRangeException from the heartbeat's delay. The retry delays arm in-process
 127        // Task.Delay timers too (timer ceiling).
 660128        AsyncResponseChannelOptions.EnsureTimerBacked(options.AckWait, nameof(NatsAsyncResponseTransportOptions), nameof
 656129        AsyncResponseChannelOptions.EnsureTimerBacked(options.PublishRetryBaseDelay, nameof(NatsAsyncResponseTransportOp
 656130        AsyncResponseChannelOptions.EnsureTimerBacked(options.PublishRetryMaxDelay, nameof(NatsAsyncResponseTransportOpt
 656131        AsyncResponseChannelOptions.EnsureTimerBacked(options.SubscriberRetryBaseDelay, nameof(NatsAsyncResponseTranspor
 654132        AsyncResponseChannelOptions.EnsureTimerBacked(options.SubscriberRetryMaxDelay, nameof(NatsAsyncResponseTransport
 654133        PositiveOrNull(options.StreamMaxMessages, nameof(options.StreamMaxMessages));
 652134        PositiveOrNull(options.DeadLetterStreamMaxMessages, nameof(options.DeadLetterStreamMaxMessages));
 135
 136        // nats-server rejects num_replicas outside 1..5 when the stream is created — at first use,
 137        // inside the subscriber retry loop as an opaque broker error retried forever — so the
 138        // bound is a named startup error here.
 652139        if (options.StreamReplicas is < 1 or > 5)
 0140            throw new InvalidOperationException(
 0141                $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.StreamReplicas)} must be between 1 and 5 (
 142
 652143        if (options.PublishMaxAttempts <= 0)
 2144            throw new InvalidOperationException($"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.PublishMax
 145
 650146        if (options.PublishRetryBaseDelay > options.PublishRetryMaxDelay)
 2147            throw new InvalidOperationException(
 2148                $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.PublishRetryBaseDelay)} cannot exceed " +
 2149                $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.PublishRetryMaxDelay)}.");
 150
 648151        if (options.SubscriberRetryBaseDelay > options.SubscriberRetryMaxDelay)
 2152            throw new InvalidOperationException(
 2153                $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.SubscriberRetryBaseDelay)} cannot exceed "
 2154                $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.SubscriberRetryMaxDelay)}.");
 646155    }
 156
 157    /// <summary>Validates the supplied subscriber options together with the transport-wide shutdown budget.</summary>
 158    public static void ValidateSubscriber(
 159        NatsAsyncResponseTransportOptions transportOptions,
 160        NatsSubscriberOptions subscriber,
 161        string role)
 162    {
 870163        ValidateSubscriber(subscriber, role);
 164
 868165        if (subscriber.AckMode is not NatsAckMode.AckAfterEnqueue)
 830166            return;
 167
 168        // NATS subscribers spend only the background drain at shutdown; the consume loop stops
 169        // with the host token and the connection teardown is not separately bounded.
 38170        ShutdownBudgetValidator.Validate(
 38171            "NATS",
 38172            $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(transportOptions.HostShutdownTimeout)}",
 38173            transportOptions.HostShutdownTimeout,
 38174            ($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BackgroundDrainTimeout)} ({role})", subscriber.Backgro
 34175    }
 176
 177    /// <summary>Validates the supplied options.</summary>
 178    public static void ValidateSubscriber(NatsSubscriberOptions subscriber, string role)
 179    {
 888180        if (subscriber.BatchSize <= 0)
 2181            throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BatchSize)} ({role}
 182
 886183        if (subscriber.MaxDeliveryAttempts < 0)
 2184            throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.MaxDeliveryAttempts
 185
 186        // The NAK redelivery delay rides the wire as nanoseconds and is honored server-side —
 187        // persistence bound, not the (smaller) in-process timer ceiling.
 884188        AsyncResponseChannelOptions.EnsurePersistedTtl(subscriber.RedeliveryDelay, nameof(NatsSubscriberOptions), $"{nam
 189
 882190        switch (subscriber.AckMode)
 191        {
 192            case NatsAckMode.AckAfterHandlerCompletes:
 832193                return;
 194
 195            case NatsAckMode.AckAfterEnqueue:
 48196                if (subscriber.BackgroundWorkerCount <= 0)
 4197                    throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BackgroundW
 44198                if (subscriber.BackgroundQueueCapacity <= 0)
 2199                    throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BackgroundQ
 42200                AsyncResponseChannelOptions.EnsureTimerBacked(subscriber.BackgroundDrainTimeout, nameof(NatsSubscriberOp
 40201                return;
 202
 203            default:
 2204                throw new InvalidOperationException(
 2205                    $"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.AckMode)} ({role}) has unsupported value '{subs
 206        }
 207    }
 208}