Skip to content

2.1.0: resilient message receive, factory fixes, dependency bumps - #40

Merged
jasonshave merged 3 commits into
masterfrom
release-2.1.0
Oct 15, 2026
Merged

jasonshave merged 3 commits into
masterfrom
release-2.1.0

Conversation

@jasonshave

@jasonshave jasonshave commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Why

Customers reported that a throwing message handler could take down their host. ReceiveMessagesAsync had three separate ways to throw out into the caller's polling loop — a failed queue read, a failed metadata read, and the customer's own exception handler throwing — and none of them were logged. In .NET 6+ an unhandled exception in a BackgroundService stops the host, so one transient storage error was enough.

This PR makes the receive path resilient, fixes the other bugs a full sweep turned up, adds the additive IHttpClientFactory-style improvements to the factory, and bumps dependencies. No existing call site needs to change. Ships as 2.1.0. The breaking redesign is tracked separately in #39.

Stability (the customer-reported crash)

  • ReceiveMessagesAsync no longer throws for a failed read, a failed deserialization, a throwing handler, a failed delete, or a throwing exception handler. Each is logged (queue name, message id, dequeue count) and passed to the customer's exception handler; the rest of the batch is still processed. Only the caller's own OperationCanceledException escapes.
  • Once a handler has succeeded, the delete is no longer tied to the caller's cancellation token, so shutdown doesn't redeliver an already-processed message.
  • A delete that fails after the handler succeeded is logged with an explanation (handler outran the visibility timeout) before being reported.
  • Queue metadata lookup failures fall back to null metadata instead of throwing.

Bugs fixed

  • Custom JsonSerializerOptions were ignored when sending (only applied on receive).
  • Client settings lived in a process-wide static; two hosts in one process shared and overwrote them, and a second AddClient("name") was silently dropped. Settings now use named options per service collection and compose.
  • QueueClientFactory was not thread-safe and made a blocking CreateIfNotExists() network call during DI resolution. Now ConcurrentDictionary + Lazy; queue creation happens once, asynchronously, on first use.
  • Trace ActivityEvents were static instances, so every event carried the process start time.
  • The queue.process_success tag was added to a struct copy and never recorded.
  • DeserializationException dropped the inner exception; Base64 sniffing mangled short valid JSON (true, null, 1234). Now tries JSON first, then falls back to Base64.
  • Send errors logged e.Message only; receive-side failures weren't logged at all.
  • queue_message_size_bytes was declared but never recorded.

Additive API

  • IAzureStorageQueueClient interface (the class implements it; the factory still returns the class) so consumers can mock the client.
  • Named clients are keyed services and the default client is a plain singleton: [FromKeyedServices("orders")] AzureStorageQueueClient works without the factory.
  • New QueueClientSettings: VisibilityTimeout, SerializerOptions (per client), ClientOptions (SDK retry policy / MessageEncoding).
  • AddClient(name, IConfiguration) and AddDefaultClient(IConfiguration) overloads.
  • Error messages for unregistered or misconfigured clients name the client and list what is registered.
  • Log messages include the queue name so multiple clients can be told apart.
  • Old LoggerMessageExtensions methods are kept but [Obsolete] for removal in 3.0.

Dependencies

  • Azure.Identity removed from the package — it was never used by the library (only by tests).
  • Azure.Storage.Queues 12.23.0 → 12.28.1, OpenTelemetry.Api 1.15.3 → 1.19.1.
  • Microsoft.Extensions.* 9.0.7 → 10.0.13. This floor is set by Azure.Core 1.60 (pulled by the new storage SDK), not by us; the 10.x packages still target .NET 8. Documented in the README upgrade notes.
  • Test projects: AutoFixture and FakeItEasy removed (FakeItEasy was unused; AutoFixture was the source of two High-severity transitive vulnerabilities via NETStandard.Library 1.6.1). Everything else bumped to latest; Microsoft.AspNetCore.Mvc.Testing stays on 8.0.x to match the target framework. dotnet list package --vulnerable --include-transitive is clean across all projects.
  • actions/checkout and actions/setup-dotnet v3 → v4 (Node 16 runtime retirement).

Docs

The logo is now the Unifinity brand mark from the brand kit (icon-256.png as the NuGet icon, dark/light SVG masters in the README via <picture>), replacing the AI-generated infinity JPEG.

README samples rewritten: they didn't compile against the current API (wrong handler arity and argument order) and ran a forever-loop inside IHostedService.StartAsync, which blocks host startup. Now BackgroundService.ExecuteAsync with cancellation. Added per-client settings, direct injection, and an "Upgrading to 2.1" section listing every behaviour change.

Behaviour changes to be aware of

  • Failed queue reads go to the exception handler instead of throwing. A loop that relied on catching that throw will no longer see it.
  • CreateIfNotExists runs on first send/receive/peek, not on DI resolution.
  • Configuration delegates run on first use of the client, not at registration; duplicate registrations compose.
  • Serializer options now apply on send.

Test plan

  • Unit tests: 34/34 pass (new tests cover every failure path above: receive throws, handler throws, exception handler throws, delete fails, deserialization fails, caller cancellation, visibility timeout passthrough, serializer options on send, registry isolation, composition, keyed injection, concurrent first access).
  • Full solution builds with 0 warnings; no vulnerable packages.
  • Integration tests against Azurite (CI).

🤖 Generated with Claude Code

Jason Shave and others added 3 commits October 15, 2026 08:03
…dependencies (2.1.0)

ReceiveMessagesAsync no longer throws for a failed read, a throwing handler,
a failed delete, or a throwing exception handler; everything is logged and
routed to the caller's exception handler and the rest of the batch is still
processed. Fixes the customer-reported host crash.

Also: per-service-collection settings via named options (was a process-wide
static), thread-safe factory with async queue creation on first use, keyed
service injection, IAzureStorageQueueClient, per-client VisibilityTimeout /
SerializerOptions / ClientOptions, serializer options honoured on send,
trace event timestamps, process_success tag, richer exceptions and logs,
README samples that compile, Azure.Identity dropped, dependencies bumped,
AutoFixture/FakeItEasy removed from tests (vulnerable transitive deps).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replaces the AI-generated infinity JPEG with the Unifinity mark from the
brand kit: icon-256.png as the NuGet package icon, and the dark/light SVG
masters in the README via a <picture> element so GitHub shows the right
colourway for the viewer's theme.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Azure.Storage.Queues 12.28 sends API version 2026-10-06, which Azurite
rejects with a 400 unless the version check is skipped. A service
container cannot take command-line arguments, so Azurite is now started
with docker run instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jasonshave
jasonshave merged commit f760477 into master Oct 15, 2026
1 check passed
@jasonshave
jasonshave deleted the release-2.1.0 branch October 15, 2026 14:18
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.

1 participant