Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/NATS.Client.Services/NatsSvcConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace NATS.Client.Services;

/// <summary>
/// Protocol constants for NATS Services.
/// </summary>
public static class NatsSvcConstants
{
/// <summary>
/// Response header carrying the service error message.
/// </summary>
public const string ServiceErrorHeader = "Nats-Service-Error";

/// <summary>
/// Response header carrying the service error code.
/// </summary>
public const string ServiceErrorCodeHeader = "Nats-Service-Error-Code";
}
8 changes: 4 additions & 4 deletions src/NATS.Client.Services/NatsSvcMsg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ public ValueTask ReplyAsync<TReply>(TReply data, NatsHeaders? headers = default,
public ValueTask ReplyErrorAsync<TReply>(int code, string message, TReply data, NatsHeaders? headers = default, string? replyTo = default, INatsSerialize<TReply>? serializer = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default)
{
headers ??= new NatsHeaders();
headers.Add("Nats-Service-Error-Code", $"{code}");
headers.Add("Nats-Service-Error", $"{message}");
headers.Add(NatsSvcConstants.ServiceErrorCodeHeader, $"{code}");
headers.Add(NatsSvcConstants.ServiceErrorHeader, $"{message}");

_endPoint?.IncrementErrors();
_endPoint?.SetLastError($"{message} ({code})");
Expand All @@ -120,8 +120,8 @@ public ValueTask ReplyErrorAsync<TReply>(int code, string message, TReply data,
public ValueTask ReplyErrorAsync(int code, string message, NatsHeaders? headers = default, string? replyTo = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default)
{
headers ??= new NatsHeaders();
headers.Add("Nats-Service-Error", $"{message}");
headers.Add("Nats-Service-Error-Code", $"{code}");
headers.Add(NatsSvcConstants.ServiceErrorHeader, $"{message}");
headers.Add(NatsSvcConstants.ServiceErrorCodeHeader, $"{code}");

_endPoint?.IncrementErrors();
_endPoint?.SetLastError($"{code}:{message}");
Expand Down
107 changes: 107 additions & 0 deletions src/NATS.Client.Services/NatsSvcMsgExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Microsoft.Extensions.Primitives;
using NATS.Client.Core;
using NATS.Client.Services;

// ReSharper disable once CheckNamespace
namespace NATS.Net;

/// <summary>
/// Extension helpers for inspecting service responses on the requester side.
/// </summary>
/// <remarks>
/// Service handlers signal errors using the <c>Nats-Service-Error</c> and
/// <c>Nats-Service-Error-Code</c> response headers (see
/// <see cref="NatsSvcMsg{T}.ReplyErrorAsync(int,string,NatsHeaders?,string?,NatsPubOpts?,CancellationToken)"/>).
/// These helpers surface that convention on the request side.
/// </remarks>
public static class NatsSvcMsgExtensions
{
/// <summary>
/// Returns <c>true</c> when the response does not carry a <c>Nats-Service-Error</c> header.
/// </summary>
/// <typeparam name="T">Message payload type.</typeparam>
/// <param name="msg">The response message to inspect.</param>
/// <param name="throwOnNoResponders">When <c>true</c> (the default), throws <see cref="NatsNoRespondersException"/> if the response is a no-responders sentinel.</param>
/// <returns><c>true</c> if the response is a service success; otherwise <c>false</c>.</returns>
/// <exception cref="NatsNoRespondersException">Thrown when <paramref name="throwOnNoResponders"/> is <c>true</c> and no service responded.</exception>
public static bool IsServiceSuccess<T>(this NatsMsg<T> msg, bool throwOnNoResponders = true)
{
if (throwOnNoResponders && msg.HasNoResponders)
{
throw new NatsNoRespondersException();
}

return msg.Headers is null || !msg.Headers.ContainsKey(NatsSvcConstants.ServiceErrorHeader);
}

/// <summary>
/// Throws <see cref="NatsSvcEndpointException"/> when the response carries a <c>Nats-Service-Error</c> header.
/// </summary>
/// <typeparam name="T">Message payload type.</typeparam>
/// <param name="msg">The response message to inspect.</param>
/// <param name="throwOnNoResponders">When <c>true</c> (the default), throws <see cref="NatsNoRespondersException"/> if the response is a no-responders sentinel.</param>
/// <returns>The same message, to allow fluent chaining.</returns>
/// <exception cref="NatsSvcEndpointException">Thrown when the response carries a service error.</exception>
/// <exception cref="NatsNoRespondersException">Thrown when <paramref name="throwOnNoResponders"/> is <c>true</c> and no service responded.</exception>
public static NatsMsg<T> EnsureServiceSuccess<T>(this NatsMsg<T> msg, bool throwOnNoResponders = true)
{
if (throwOnNoResponders && msg.HasNoResponders)
{
throw new NatsNoRespondersException();
}

var status = msg.GetServiceStatus(throwOnNoResponders: false);
if (status.Message is not null)
{
throw new NatsSvcEndpointException(status.Code, status.Message);
}

return msg;
}

/// <summary>
/// Reads the service status from the response, combining the <c>Nats-Service-Error</c> /
/// <c>Nats-Service-Error-Code</c> headers with the no-responders sentinel.
/// </summary>
/// <remarks>
/// When a header is present multiple times (e.g. a reply that emitted the header line more than once),
/// the last value wins.
/// </remarks>
/// <typeparam name="T">Message payload type.</typeparam>
/// <param name="msg">The response message to inspect.</param>
/// <param name="throwOnNoResponders">When <c>true</c> (the default), throws <see cref="NatsNoRespondersException"/> if the response is a no-responders sentinel.</param>
/// <returns>The parsed <see cref="NatsSvcStatus"/>.</returns>
/// <exception cref="NatsNoRespondersException">Thrown when <paramref name="throwOnNoResponders"/> is <c>true</c> and no service responded.</exception>
public static NatsSvcStatus GetServiceStatus<T>(this NatsMsg<T> msg, bool throwOnNoResponders = true)
{
if (msg.HasNoResponders)
{
if (throwOnNoResponders)
{
throw new NatsNoRespondersException();
}

return NatsSvcStatus.NoResponders;
}

var headers = msg.Headers;
if (headers is null || !headers.TryGetValue(NatsSvcConstants.ServiceErrorHeader, out var errValue))
{
return NatsSvcStatus.Success;
}

var message = LastValueOrEmpty(errValue);

var code = 0;
if (headers.TryGetValue(NatsSvcConstants.ServiceErrorCodeHeader, out var codeValue)
&& int.TryParse(LastValueOrEmpty(codeValue), out var parsed))
{
code = parsed;
}

return NatsSvcStatus.FromError(code, message);
}

private static string LastValueOrEmpty(StringValues values)
=> values.Count > 0 ? values[values.Count - 1] ?? string.Empty : string.Empty;
}
42 changes: 42 additions & 0 deletions src/NATS.Client.Services/NatsSvcStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace NATS.Client.Services;

/// <summary>
/// Status of a NATS service response, derived from the <c>Nats-Service-Error</c>
/// and <c>Nats-Service-Error-Code</c> response headers and the no-responders sentinel.
/// </summary>
public readonly struct NatsSvcStatus
{
private NatsSvcStatus(int code, string? message, bool hasNoResponders)
{
Code = code;
Message = message;
HasNoResponders = hasNoResponders;
}

/// <summary>
/// Error code from the <c>Nats-Service-Error-Code</c> header. <c>0</c> when the header
/// is missing or not an integer, or when the response is a success.
/// </summary>
public int Code { get; }

/// <summary>
/// Error message from the <c>Nats-Service-Error</c> header, or <c>null</c> when absent.
/// </summary>
public string? Message { get; }

/// <summary>
/// <c>true</c> when the response is a no-responders sentinel (no service was listening).
/// </summary>
public bool HasNoResponders { get; }

/// <summary>
/// <c>true</c> when the response carries no service error and is not a no-responders sentinel.
/// </summary>
public bool IsSuccess => Message is null && !HasNoResponders;

internal static NatsSvcStatus Success { get; } = new(0, null, false);

internal static NatsSvcStatus NoResponders { get; } = new(0, null, true);

internal static NatsSvcStatus FromError(int code, string message) => new(code, message, false);
}
194 changes: 194 additions & 0 deletions tests/NATS.Client.Services.Tests/NatsSvcMsgExtensionsTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using Microsoft.Extensions.Primitives;
using NATS.Net;

namespace NATS.Client.Services.Tests;

public class NatsSvcMsgExtensionsTest
{
[Fact]
public void IsServiceSuccess_returns_true_when_no_headers()
{
Msg().IsServiceSuccess().Should().BeTrue();
}

[Fact]
public void IsServiceSuccess_returns_true_when_error_header_absent()
{
var headers = new NatsHeaders { { "X-Other", "value" } };

Msg(headers).IsServiceSuccess().Should().BeTrue();
}

[Fact]
public void IsServiceSuccess_returns_false_when_error_header_present()
{
var headers = new NatsHeaders
{
{ NatsSvcConstants.ServiceErrorHeader, "boom" },
{ NatsSvcConstants.ServiceErrorCodeHeader, "500" },
};

Msg(headers).IsServiceSuccess().Should().BeFalse();
}

[Fact]
public void IsServiceSuccess_throws_on_no_responders_by_default()
{
var msg = Msg(flags: NatsMsgFlags.NoResponders);

msg.Invoking(m => m.IsServiceSuccess())
.Should().Throw<NatsNoRespondersException>();
}

[Fact]
public void IsServiceSuccess_ignores_no_responders_when_opted_out()
{
Msg(flags: NatsMsgFlags.NoResponders)
.IsServiceSuccess(throwOnNoResponders: false)
.Should().BeTrue();
}

[Fact]
public void GetServiceStatus_returns_success_when_no_headers()
{
var status = Msg().GetServiceStatus();

status.IsSuccess.Should().BeTrue();
status.Code.Should().Be(0);
status.Message.Should().BeNull();
status.HasNoResponders.Should().BeFalse();
}

[Fact]
public void GetServiceStatus_returns_code_and_message_when_present()
{
var headers = new NatsHeaders
{
{ NatsSvcConstants.ServiceErrorHeader, "Division by zero" },
{ NatsSvcConstants.ServiceErrorCodeHeader, "400" },
};

var status = Msg(headers).GetServiceStatus();

status.IsSuccess.Should().BeFalse();
status.Code.Should().Be(400);
status.Message.Should().Be("Division by zero");
status.HasNoResponders.Should().BeFalse();
}

[Fact]
public void GetServiceStatus_defaults_code_to_zero_when_code_header_missing()
{
var headers = new NatsHeaders { { NatsSvcConstants.ServiceErrorHeader, "no code" } };

var status = Msg(headers).GetServiceStatus();

status.IsSuccess.Should().BeFalse();
status.Code.Should().Be(0);
status.Message.Should().Be("no code");
}

[Fact]
public void GetServiceStatus_defaults_code_to_zero_when_code_header_not_an_int()
{
var headers = new NatsHeaders
{
{ NatsSvcConstants.ServiceErrorHeader, "bad code" },
{ NatsSvcConstants.ServiceErrorCodeHeader, "not-a-number" },
};

var status = Msg(headers).GetServiceStatus();

status.IsSuccess.Should().BeFalse();
status.Code.Should().Be(0);
status.Message.Should().Be("bad code");
}

[Fact]
public void GetServiceStatus_takes_last_value_when_header_appears_multiple_times()
{
var headers = new NatsHeaders
{
[NatsSvcConstants.ServiceErrorHeader] = new StringValues(new[] { "first", "last" }),
[NatsSvcConstants.ServiceErrorCodeHeader] = new StringValues(new[] { "111", "222" }),
};

var status = Msg(headers).GetServiceStatus();

status.Code.Should().Be(222);
status.Message.Should().Be("last");
}

[Fact]
public void GetServiceStatus_throws_on_no_responders_by_default()
{
var msg = Msg(flags: NatsMsgFlags.NoResponders);

msg.Invoking(m => m.GetServiceStatus())
.Should().Throw<NatsNoRespondersException>();
}

[Fact]
public void GetServiceStatus_returns_no_responders_status_when_opted_out()
{
var status = Msg(flags: NatsMsgFlags.NoResponders).GetServiceStatus(throwOnNoResponders: false);

status.IsSuccess.Should().BeFalse();
status.HasNoResponders.Should().BeTrue();
status.Code.Should().Be(0);
status.Message.Should().BeNull();
}

[Fact]
public void EnsureServiceSuccess_returns_message_when_no_error()
{
var result = Msg(data: 42).EnsureServiceSuccess();

result.Data.Should().Be(42);
}

[Fact]
public void EnsureServiceSuccess_throws_with_code_and_message()
{
var headers = new NatsHeaders
{
{ NatsSvcConstants.ServiceErrorHeader, "Division by zero" },
{ NatsSvcConstants.ServiceErrorCodeHeader, "400" },
};
var msg = Msg(headers);

var ex = msg.Invoking(m => m.EnsureServiceSuccess()).Should().Throw<NatsSvcEndpointException>().Which;
ex.Code.Should().Be(400);
ex.Message.Should().Be("Division by zero");
}

[Fact]
public void EnsureServiceSuccess_throws_on_no_responders_by_default()
{
var msg = Msg(flags: NatsMsgFlags.NoResponders);

msg.Invoking(m => m.EnsureServiceSuccess())
.Should().Throw<NatsNoRespondersException>();
}

[Fact]
public void EnsureServiceSuccess_ignores_no_responders_when_opted_out()
{
var msg = Msg(flags: NatsMsgFlags.NoResponders);

msg.EnsureServiceSuccess(throwOnNoResponders: false).HasNoResponders.Should().BeTrue();
}

[Fact]
public void EnsureServiceSuccess_prefers_no_responders_over_service_error()
{
var headers = new NatsHeaders { { NatsSvcConstants.ServiceErrorHeader, "boom" } };
var msg = Msg(headers, flags: NatsMsgFlags.NoResponders);

msg.Invoking(m => m.EnsureServiceSuccess())
.Should().Throw<NatsNoRespondersException>();
}

private static NatsMsg<int> Msg(NatsHeaders? headers = null, int data = 0, NatsMsgFlags flags = NatsMsgFlags.None)
=> new("subject", replyTo: null, size: 0, headers: headers, data: data, connection: null, flags: flags);
}
Loading