Skip to content

Refactor Stream gRPC handlers to remove exception handling#34

Merged
yordis merged 1 commit intomasterfrom
fix/grpc-exception-swallowing
Dec 28, 2025
Merged

Refactor Stream gRPC handlers to remove exception handling#34
yordis merged 1 commit intomasterfrom
fix/grpc-exception-swallowing

Conversation

@yordis
Copy link
Member

@yordis yordis commented Dec 28, 2025

Changed: Simplified the BatchAppend method by removing the try-catch blocks for IOException, TaskCanceledException, InvalidOperationException, and OperationCanceledException, as they were previously ignored. This streamlines the code and improves readability.

…exceptions

Changed: Simplified the BatchAppend method by removing the try-catch blocks for IOException, TaskCanceledException, InvalidOperationException, and OperationCanceledException, as they were previously ignored. This streamlines the code and improves readability.
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
@cursor
Copy link

cursor bot commented Dec 28, 2025

PR Summary

Refactors gRPC Streams to streamline control flow and modernize files.

  • Simplifies BatchAppend: removes outer try/catch that previously swallowed IOException/cancellation exceptions; directly awaits worker.Work with existing internal handling
  • Simplifies Read: drops ignored IOException/cancellation catches; now only handles ReadResponseException and rethrows others with duration tracking
  • File-scoped namespaces + minor refactors: migrate to file-scoped namespace and relocate ClientWriteRequest out of the worker class (no API changes)

Written by Cursor Bugbot for commit 9df52db. This will update automatically on new commits. Configure here.

@coderabbitai
Copy link

coderabbitai bot commented Dec 28, 2025

Walkthrough

Structural refactoring of two gRPC streaming handlers: BatchAppend simplifies control flow by removing exception-swallowing try/catch blocks, relocating type declarations and static helpers; Read adds authorization checks before streaming results and reorganizes error handling with try/catch wrapping.

Changes

Cohort / File(s) Summary
Streams.BatchAppend refactor
src/EventStore.Core/Services/Transport/Grpc/Streams.BatchAppend.cs
Removed surrounding try/catch blocks that previously swallowed IO, TaskCanceledException, InvalidOperationException, and OperationCanceledException; simplified BatchAppend method to construct worker and await Work without exception handling; relocated ClientWriteRequest type from nested to private record scope; reorganized static helper methods (FromOptions, FromProposedMessage, ToInternalMessage, GetRequestedTimeout, Min/Max) and adjusted their interaction with write pipeline and cancellation tokens.
Streams.Read authorization & error handling
src/EventStore.Core/Services/Transport/Grpc/Streams.Read.cs
Added authorization check flow via _provider.CheckAccessAsync before enumerating results; introduced user extraction from HTTP context and requiresLeader computation; wrapped enumerator usage in try/catch with ReadResponseException conversion; preserved enumeration and response conversion paths; no external public interface changes.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant Server as Server<br/>(Read Handler)
    participant AuthProvider as Auth Provider
    participant StreamStore as Stream Store
    
    Client->>Server: Request stream read
    Server->>Server: Extract user from<br/>HTTP context
    Server->>AuthProvider: CheckAccessAsync(user)
    alt Authorization denied
        AuthProvider-->>Server: Access denied
        Server-->>Client: Error response
    else Authorization granted
        AuthProvider-->>Server: OK
        Server->>StreamStore: CreateEnumerator
        loop For each event
            StreamStore-->>Server: ReadResponse
            Server->>Server: TryConvertReadResponse<br/>→ ReadResp
            Server-->>Client: Send ReadResp
        end
        Server->>Server: Register context<br/>cancellation cleanup
        Server-->>Client: Stream complete
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 ✨
Structures now flow cleaner, no swallows in sight,
Auth guards the gateway, both read and write,
Workers rebuilt, helpers rearranged with care,
Control flows direct—exceptions laid bare! 🎯

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the refactoring: removal of try-catch blocks for specific ignored exceptions to improve code clarity.
Title check ✅ Passed The PR title 'Refactor Stream gRPC handlers to remove exception handling' is accurate and directly reflects the main objective described in the PR: removing try-catch blocks from exception handling in gRPC stream handlers, though it slightly broadens the scope to mention 'Stream' (plural) rather than specifically 'BatchAppend'.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/grpc-exception-swallowing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/EventStore.Core/Services/Transport/Grpc/Streams.Read.cs (1)

23-90: Well-structured authorization and streaming flow.

The Read method properly:

  1. Validates uuidOption before use
  2. Checks access via _provider.CheckAccessAsync before creating the enumerator
  3. Uses proper disposal pattern with await using

However, a minor concern with the DisposeEnumerator pattern:

The async void method at line 73 could silently swallow exceptions during disposal. While this is a common pattern for cancellation-triggered cleanup, consider logging disposal failures.

🔎 Suggested improvement
-async void DisposeEnumerator() => await enumerator.DisposeAsync();
+async void DisposeEnumerator() {
+    try {
+        await enumerator.DisposeAsync();
+    } catch (Exception ex) {
+        // Log but don't propagate - this is cleanup during cancellation
+        Log.Debug(ex, "Exception during enumerator disposal");
+    }
+}
src/EventStore.Core/Services/Transport/Grpc/Streams.BatchAppend.cs (2)

139-148: Authorization check before stream identifier validation.

The authorization check at lines 139-148 occurs before validating that StreamIdentifier is not null (lines 150-158). If StreamIdentifier is null, the authorization check still runs with request.Options.StreamIdentifier potentially being null.

Consider reordering to validate StreamIdentifier first:

🔎 Suggested fix
 if (request.Options != null) {
     var timeout = Min(GetRequestedTimeout(request.Options), _writeTimeout);

+    if (request.Options.StreamIdentifier == null) {
+        await writer.WriteAsync(new BatchAppendResp {
+            CorrelationId = request.CorrelationId,
+            StreamIdentifier = request.Options.StreamIdentifier,
+            Error = Status.BadRequest(
+                $"Required field {nameof(request.Options.StreamIdentifier)} not set.")
+        }, cancellationToken);
+        continue;
+    }
+
     if (!await _authorizationProvider.CheckAccessAsync(user, WriteOperation.WithParameter(
         Plugins.Authorization.Operations.Streams.Parameters.StreamId(
             request.Options.StreamIdentifier)), cancellationToken)) {
         await writer.WriteAsync(new BatchAppendResp {
             CorrelationId = request.CorrelationId,
             StreamIdentifier = request.Options.StreamIdentifier,
             Error = Status.AccessDenied
         }, cancellationToken);
         continue;
     }

-    if (request.Options.StreamIdentifier == null) {
-        await writer.WriteAsync(new BatchAppendResp {
-            CorrelationId = request.CorrelationId,
-            StreamIdentifier = request.Options.StreamIdentifier,
-            Error = Status.BadRequest(
-                $"Required field {nameof(request.Options.StreamIdentifier)} not set.")
-        }, cancellationToken);
-        continue;
-    }

326-335: Fire-and-forget timeout task may cause issues.

The Task.Delay(...).ContinueWith(...) pattern at line 334 starts a fire-and-forget timeout. If the ClientWriteRequest completes before the timeout, the continuation still runs and calls onTimeout(), which attempts to remove from pendingWrites and write a timeout response.

The TryRemove will return false if already removed, preventing the timeout response — this is correct. However, the timeout task keeps running unnecessarily.

Consider using a CancellationTokenSource that can be cancelled when the request completes to avoid unnecessary timer continuations:

This is a minor optimization. The current implementation is functionally correct due to the TryRemove guard in onTimeout.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 33cac4b and 9df52db.

📒 Files selected for processing (2)
  • src/EventStore.Core/Services/Transport/Grpc/Streams.BatchAppend.cs
  • src/EventStore.Core/Services/Transport/Grpc/Streams.Read.cs
🧰 Additional context used
🧬 Code graph analysis (1)
src/EventStore.Core/Services/Transport/Grpc/Streams.Read.cs (3)
src/EventStore.Core/Services/Transport/Grpc/ReadReq.cs (6)
  • Core (28-34)
  • Core (36-42)
  • ReadReq (7-47)
  • Options (9-45)
  • StreamRevision (12-17)
  • StreamRevision (19-24)
src/EventStore.Core/Services/Transport/Grpc/ReadResp.cs (2)
  • ReadResp (4-10)
  • ReadEvent (6-8)
src/EventStore.Core/Services/Transport/Grpc/RpcExceptions.cs (11)
  • RpcExceptions (10-248)
  • Exception (11-11)
  • Exception (19-20)
  • Exception (167-176)
  • Exception (178-186)
  • Exception (188-196)
  • Exception (198-207)
  • Exception (209-217)
  • Exception (219-223)
  • Exception (225-229)
  • Exception (231-235)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: Build / noble
  • GitHub Check: Build / bookworm-slim
  • GitHub Check: Docker Compose Smoke Test
🔇 Additional comments (10)
src/EventStore.Core/Services/Transport/Grpc/Streams.Read.cs (6)

83-89: Verify exception re-throw behavior after duration tracking.

The outer catch block at lines 86-89 sets the exception on duration tracking and re-throws. However, the inner catch at lines 83-85 for ReadResponseException calls ConvertReadResponseException which always throws a new RpcException.

This means ReadResponseException instances will:

  1. Be caught at line 83
  2. Converted and thrown as RpcException at line 84
  3. Caught again at line 86, tracked, and re-thrown

This is correct behavior for duration tracking, but confirm that tracking RpcException (not the original ReadResponseException) is the intended behavior.


92-239: Large switch expression is well-organized but consider documenting unsupported combinations.

The CreateEnumerator method correctly routes to appropriate enumerator implementations based on the combination of stream options, count options, read direction, and filter options.

The final catch-all at lines 236-238 throws RpcExceptions.InvalidCombination for unsupported combinations. This is good defensive programming.


241-252: LGTM!

The ConvertToEventFilter method cleanly handles both event type and stream identifier filters with prefix and regex options.


254-289: LGTM!

The TryConvertReadResponse pattern correctly returns false for SubscriptionFellBehind (which is not sent to clients) and throws for unknown response types.


291-316: LGTM!

The ConvertReadResponseException method provides exhaustive mapping of internal exceptions to gRPC exceptions with a clear fallback for unknown types.


318-364: LGTM!

The ConvertToRecordedEvent and ConvertToReadEvent methods properly handle nullable positions and the UUID format option.

src/EventStore.Core/Services/Transport/Grpc/Streams.BatchAppend.cs (4)

70-74: Channel configuration allows multiple writers but only one is used.

The channel is configured with SingleWriter = false, but the Receive method is the only writer (besides the callback in ConvertMessage). The callback at line 201 uses TryWrite, which is safe.

This configuration is correct given that the CallbackEnvelope callbacks can write from different threads.


94-109: async void handler swallows some exceptions but propagates others.

The HandleCompletion method:

  • Catches OperationCanceledException and sets cancellation on TCS
  • Catches IOException and logs + sets exception
  • Re-throws other exceptions via TrySetException

This is appropriate for the continuation pattern used here. The exceptions are properly surfaced through the TaskCompletionSource.


317-345: ClientWriteRequest record placement and design.

The ClientWriteRequest is now a private record at class scope (lines 317-345), which improves organization compared to being nested inside the worker.

The record uses mutable internal state (_events list, _size counter) which is appropriate for the accumulation pattern but note that records typically suggest immutability. The implementation is correct for the use case.


33-41: The code already handles these exceptions appropriately. The BatchAppendWorker.Work() method catches OperationCanceledException, IOException, and other exceptions in HandleCompletion() and propagates them via the task completion source, which then propagates to the gRPC framework. The gRPC framework automatically converts these exceptions to appropriate RPC Status codes (e.g., CANCELLED for OperationCanceledException, UNAVAILABLE/INTERNAL for IOException). No additional upstream exception handling is needed—this is standard gRPC behavior and properly manages client experience.

@yordis yordis changed the title Refactor BatchAppend to remove exception handling Refactor Stream gRPC handlers to remove exception handling Dec 28, 2025
@yordis yordis merged commit 895624f into master Dec 28, 2025
8 checks passed
@yordis yordis deleted the fix/grpc-exception-swallowing branch December 28, 2025 21:02
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