Skip to content

Add helpers to detect Nats-Service-Error on responses - #1152

Merged
mtmk merged 2 commits into
mainfrom
services-ensure-success
May 27, 2026
Merged

Add helpers to detect Nats-Service-Error on responses#1152
mtmk merged 2 commits into
mainfrom
services-ensure-success

Conversation

@mtmk

@mtmk mtmk commented May 20, 2026

Copy link
Copy Markdown
Member

Service handlers signal errors using Nats-Service-Error / Nats-Service-Error-Code response headers, but until now the requester side had no way to surface that without reading headers manually. Adds IsServiceSuccess(), EnsureServiceSuccess(), and GetServiceStatus() extensions on NatsMsg<T>, plus a NatsSvcStatus struct and a NatsSvcConstants class that centralizes the header names previously duplicated across the producer side.

The shape mirrors HttpResponseMessage: IsServiceSuccess ~ IsSuccessStatusCode, EnsureServiceSuccess ~ EnsureSuccessStatusCode, and GetServiceStatus returns a struct that rolls StatusCode + ReasonPhrase into one value.

Fixes #496

@synadia-claude-reviewer

synadia-claude-reviewer Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @mtmk's task in 3m 32s —— View job


PR Review

  • Gather context (CLAUDE.md, PR diff)
  • Review NatsSvcConstants.cs
  • Review NatsSvcMsg.cs modifications
  • Review NatsSvcMsgExtensions.cs
  • Review NatsSvcStatus.cs
  • Review test file NatsSvcMsgExtensionsTest.cs
  • Post final review summary

Summary

The implementation is clean and well-structured. The NatsSvcConstants refactor is a no-brainer improvement, NatsSvcStatus as a readonly struct is the right choice, and the test coverage is thorough. Three issues worth addressing:

1. IsServiceSuccess() is inconsistent with GetServiceStatus().IsSuccess for no-responders (real correctness risk)

msg.IsServiceSuccess() returns true when HasNoResponders is set (no error header is present), but msg.GetServiceStatus().IsSuccess returns false for the same message. The existing NatsMsg<T>.EnsureSuccess() treats no-responders as a failure by throwing, so IsServiceSuccess() diverges from established convention. A user writing if (msg.IsServiceSuccess()) { use(msg.Data) } will silently process an empty no-responders message. See inline comment.

2. Tests bypass NatsSvcConstants (defeats the refactoring safety)

All test methods that set up service-error headers hard-code the string literals "Nats-Service-Error" / "Nats-Service-Error-Code" instead of using NatsSvcConstants.ServiceErrorHeader / NatsSvcConstants.ServiceErrorCodeHeader. If the constants are ever changed, these tests won't catch the regression. See inline comment.

3. codeValue.ToString() on StringValues when parsing the error code (minor)

Using codeValue[0] is more explicit about reading the first value and avoids a join allocation in the (unlikely) multi-value edge case. See inline comment.

@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown

/// <param name="msg">The response message to inspect.</param>
/// <returns><c>true</c> if the response is a service success; otherwise <c>false</c>.</returns>
public static bool IsServiceSuccess<T>(this NatsMsg<T> msg)
=> msg.Headers is null || !msg.Headers.ContainsKey(NatsSvcConstants.ServiceErrorHeader);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API inconsistency with GetServiceStatus().IsSuccess

IsServiceSuccess() returns true when HasNoResponders is set because there's no service error header. But GetServiceStatus().IsSuccess returns false for the same message (no-responders is explicitly treated as non-success).

A user calling if (msg.IsServiceSuccess()) on a no-responders message will enter the success branch and try to use msg.Data, which is meaningless. The existing NatsMsg<T>.EnsureSuccess() treats no-responders as a failure (throws), so this diverges from established convention.

Consider either:

  • Renaming to IsServiceError() (returns true only when an error header is present — no semantic claim about no-responders)
  • Or having IsServiceSuccess() also check !HasNoResponders, consistent with GetServiceStatus().IsSuccess

The throwOnNoResponders overload covers the explicit opt-in path, but the zero-argument overload will silently treat no-responders as success.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d92ad90 by flipping the default: all three helpers (IsServiceSuccess, EnsureServiceSuccess, GetServiceStatus) now throw NatsNoRespondersException on no-responders by default, with a throwOnNoResponders: false opt-out for load-balancing / failover callers. The common path raises instead of silently returning a misleading bool or struct.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StringValues.ToString() joins multiple values with ", " when there are multiple header values. If a response carries duplicate Nats-Service-Error-Code headers, int.TryParse will silently fail and code stays 0. This is safe (defaulting to 0 is documented), but using codeValue[0] (first value) is slightly more explicit about the intent and avoids the join allocation in the multi-value edge case:

Suggested change
&& int.TryParse(codeValue.ToString(), out var parsed))
if (headers.TryGetValue(NatsSvcConstants.ServiceErrorCodeHeader, out var codeValue)
&& int.TryParse(codeValue[0], out var parsed))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took a different angle in d92ad90: switched both header reads to last-value-wins via a LastValueOrEmpty helper. While digging into this I checked nats-server source first and confirmed the server doesn't touch these headers at all (the Services protocol is purely client-side), so the original framework-appends-after-user reasoning didn't apply. The realistic multi-value path is the wire parser (NatsHeaderParser.cs:233) using StringValues.Concat on duplicate header lines, so last-wins defends against a malformed reply emitting Nats-Service-Error twice. Skipped the bare [0] form because StringValues' indexer returns string? which trips TreatWarningsAsErrors on netstandard 2.0; the helper handles that and an empty StringValues safely.

{
var headers = new NatsHeaders
{
{ "Nats-Service-Error", "boom" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests use hard-coded string literals instead of NatsSvcConstants.ServiceErrorHeader / NatsSvcConstants.ServiceErrorCodeHeader. One of the goals of NatsSvcConstants is to be the single source of truth for these header names, but tests bypass it. If the constant values were ever changed, these tests would not catch the regression.

Suggested change
{ "Nats-Service-Error", "boom" },
{ NatsSvcConstants.ServiceErrorHeader, "boom" },
{ NatsSvcConstants.ServiceErrorCodeHeader, "500" },

Same applies to the other test methods that hard-code these strings (GetServiceStatus_returns_code_and_message_when_present, EnsureServiceSuccess_throws_with_code_and_message, etc.).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched my new unit tests to NatsSvcConstants in d92ad90. The pre-existing integration tests in ServicesTests.cs and CheckNativeAot/Program.cs keep the literals on purpose: those pin the on-wire format end-to-end, so if a constant ever drifted from the spec they should fail loudly.

@mtmk
mtmk requested a review from scottf May 20, 2026 10:43

@scottf scottf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mtmk
mtmk merged commit 7d64ac7 into main May 27, 2026
33 checks passed
@mtmk
mtmk deleted the services-ensure-success branch May 27, 2026 09:33
This was referenced May 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server errors aren't throwing exceptions

2 participants