| | | 1 | | using Microsoft.Extensions.Logging; |
| | | 2 | | using System.Diagnostics; |
| | | 3 | | using System.Diagnostics.CodeAnalysis; |
| | | 4 | | |
| | | 5 | | namespace AsyncResponse; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// The one place the portable correlation-id contract |
| | | 9 | | /// (<see cref="AsyncResponseChannelOptions.CorrelationIdNotPortable"/>) is applied to a public |
| | | 10 | | /// string, so that every channel enforces the same rule at the same boundary. An id that violates |
| | | 11 | | /// it is not a cosmetic problem: it is truncated or rejected at its first relational write, and a |
| | | 12 | | /// space-padded one is the SAME key as its trimmed form to a database while the library compares |
| | | 13 | | /// ids ordinally — a response stored under it can surface at another waiter. |
| | | 14 | | /// <para> |
| | | 15 | | /// Two failures, told apart because callers can do different things about them. A BLANK id carries |
| | | 16 | | /// no information to act on, so it is logged and skipped wherever it appears — that has always been |
| | | 17 | | /// the behaviour and an inbound message with no id can only be dropped. A NON-BLANK id that breaks |
| | | 18 | | /// the contract is a caller bug: the library throws it back at every public entry point, because |
| | | 19 | | /// swallowing it turns a typo into a waiter that simply times out much later, with nothing at the |
| | | 20 | | /// call site to explain why. Only the untrusted edge — <see cref="IAsyncResponseIngress"/> and the |
| | | 21 | | /// raw publish path it drives — downgrades that to a drop, since a broker message that throws comes |
| | | 22 | | /// straight back around on redelivery, forever. |
| | | 23 | | /// </para> |
| | | 24 | | /// </summary> |
| | | 25 | | internal static class CorrelationIdGuard |
| | | 26 | | { |
| | | 27 | | /// <summary> |
| | | 28 | | /// Validates an id the application supplied for a WAIT, before any subscription or |
| | | 29 | | /// recovery-state side effect exists to leak. |
| | | 30 | | /// </summary> |
| | | 31 | | internal static void ThrowIfUnusable([NotNull] string? correlationId) |
| | | 32 | | { |
| | 7096 | 33 | | if (string.IsNullOrWhiteSpace(correlationId)) |
| | 16 | 34 | | throw new ArgumentNullException(nameof(correlationId), "CorrelationId must not be empty or whitespace."); |
| | | 35 | | |
| | 7080 | 36 | | if (AsyncResponseChannelOptions.CorrelationIdNotPortable(correlationId) is { } rejection) |
| | 48 | 37 | | throw new ArgumentException(rejection, nameof(correlationId)); |
| | 7032 | 38 | | } |
| | | 39 | | |
| | | 40 | | /// <summary> |
| | | 41 | | /// Classifies an id without acting on it: the shared answer behind every caller below, and |
| | | 42 | | /// behind the ingress's own drop decision. |
| | | 43 | | /// </summary> |
| | | 44 | | internal static bool IsUnroutable([NotNullWhen(false)] string? correlationId, out UnroutableReason reason) |
| | | 45 | | { |
| | 7024 | 46 | | if (string.IsNullOrWhiteSpace(correlationId)) |
| | | 47 | | { |
| | 58 | 48 | | reason = new("correlation_id_null", "no correlation id", ContractViolation: false); |
| | 58 | 49 | | return true; |
| | | 50 | | } |
| | | 51 | | |
| | 6966 | 52 | | if (AsyncResponseChannelOptions.CorrelationIdNotPortable(correlationId) is { } rejection) |
| | | 53 | | { |
| | 49 | 54 | | reason = new("correlation_id_not_portable", rejection, ContractViolation: true); |
| | 49 | 55 | | return true; |
| | | 56 | | } |
| | | 57 | | |
| | 6917 | 58 | | reason = default; |
| | 6917 | 59 | | return false; |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | /// <summary> |
| | | 63 | | /// Guards a PUBLISH. Returns <c>false</c> — narrowing <paramref name="correlationId"/> to |
| | | 64 | | /// non-null — when the publish may proceed, and <c>true</c> when it must be abandoned, having |
| | | 65 | | /// already logged the reason and marked <paramref name="activity"/>. A non-blank id that breaks |
| | | 66 | | /// the contract throws instead, unless <paramref name="dropContractViolations"/> says this |
| | | 67 | | /// caller is the untrusted edge. |
| | | 68 | | /// </summary> |
| | | 69 | | /// <param name="correlationId">The id the publish was addressed to.</param> |
| | | 70 | | /// <param name="logger">The publishing channel's logger.</param> |
| | | 71 | | /// <param name="activity">The publish activity, marked with the failure when there is one.</param> |
| | | 72 | | /// <param name="what">What is being dropped, for the log message: "the response", "the exception", …</param> |
| | | 73 | | /// <param name="dropped"> |
| | | 74 | | /// The exception a <c>SetException</c> publish was carrying; included in the log so the |
| | | 75 | | /// technical failure it described is not lost along with its unroutable id. |
| | | 76 | | /// </param> |
| | | 77 | | /// <param name="dropContractViolations"> |
| | | 78 | | /// <c>true</c> only on the raw publish path, which exists to serve inbound broker messages: |
| | | 79 | | /// throwing there would fail the delivery, and the transport would redeliver an id that can |
| | | 80 | | /// never become valid. The ingress rejects such ids before this point; this is the backstop. |
| | | 81 | | /// </param> |
| | | 82 | | internal static bool IsUnpublishable( |
| | | 83 | | [NotNullWhen(false)] string? correlationId, |
| | | 84 | | ILogger logger, |
| | | 85 | | Activity? activity, |
| | | 86 | | string what, |
| | | 87 | | Exception? dropped = null, |
| | | 88 | | bool dropContractViolations = false) |
| | | 89 | | { |
| | 6895 | 90 | | if (!IsUnroutable(correlationId, out var reason)) |
| | 6808 | 91 | | return false; |
| | | 92 | | |
| | 87 | 93 | | if (reason.ContractViolation && !dropContractViolations) |
| | 28 | 94 | | throw new ArgumentException(reason.Description, nameof(correlationId)); |
| | | 95 | | |
| | 59 | 96 | | if (reason.ContractViolation) |
| | | 97 | | { |
| | | 98 | | // Error, not warning: unlike a missing id this one looks routable, so left alone it |
| | | 99 | | // would fail much later — at the storage write, or worse, at somebody else's waiter. |
| | 7 | 100 | | if (dropped is null) |
| | 7 | 101 | | logger.LogError("Cannot publish {What}; the correlation id is outside the portable contract. {Rejection} |
| | | 102 | | else |
| | 0 | 103 | | logger.LogError("Cannot publish {What}; the correlation id is outside the portable contract. {Rejection} |
| | | 104 | | } |
| | 52 | 105 | | else if (dropped is null) |
| | | 106 | | { |
| | 35 | 107 | | logger.LogWarning("CorrelationId is null; cannot publish {What}.", what); |
| | | 108 | | } |
| | | 109 | | else |
| | | 110 | | { |
| | 17 | 111 | | logger.LogWarning("CorrelationId is null; cannot publish {What}. Exception: {ExceptionMessage}", what, dropp |
| | | 112 | | } |
| | | 113 | | |
| | 59 | 114 | | AsyncResponseDiagnostics.SetError(activity, reason.ErrorType, $"Cannot publish {what}: {reason.Description}."); |
| | 59 | 115 | | return true; |
| | | 116 | | } |
| | | 117 | | |
| | | 118 | | /// <summary> |
| | | 119 | | /// Why an id cannot route: the span's <c>error.type</c> tag, the human-readable phrase, and |
| | | 120 | | /// whether it is a caller's contract violation (throwable) rather than a missing id. |
| | | 121 | | /// </summary> |
| | 359 | 122 | | internal readonly record struct UnroutableReason(string ErrorType, string Description, bool ContractViolation); |
| | | 123 | | } |