Skip to content

Feature/async grpc exception handler#6717

Open
Hun425 wants to merge 39 commits into
line:mainfrom
Hun425:feature/async-grpc-exception-handler
Open

Feature/async grpc exception handler#6717
Hun425 wants to merge 39 commits into
line:mainfrom
Hun425:feature/async-grpc-exception-handler

Conversation

@Hun425
Copy link
Copy Markdown

@Hun425 Hun425 commented Apr 10, 2026

Motivation:

GrpcExceptionHandlerFunction.apply() is synchronous and invoked inside AbstractServerCall.close() on the event loop. When exception handling needs async work (e.g., i18n lookup from a remote store), users are forced to use runBlocking or CompletableFuture.join(), risking event loop starvation.

Modifications:

  • Added AsyncGrpcExceptionHandlerFunction returning CompletableFuture<@Nullable Status>, with orElse() chaining.
  • Extended InternalGrpcExceptionHandler to support async delegation with sync fallback when the async handler returns null.
  • Added async branches in AbstractServerCall.close() and FramedGrpcService timeout/cancellation paths. Final close() is scheduled on ctx.eventLoop().
  • Added GrpcServiceBuilder.asyncExceptionHandler(). Can be combined with exceptionHandler() — async is tried first, sync is the fallback.
  • Added tests covering custom status, metadata mutation, null fallback, orElse chaining, and handler failure.

Result:

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces asynchronous exception handling to gRPC services. It adds async-first APIs (handleAsync, applyAsync, ofAsync) to exception handler interfaces and updates internal server components to use CompletableFuture-based async operations, enabling non-blocking async tasks (e.g., i18n lookups) during error handling without blocking event loop threads.

Changes

Cohort / File(s) Summary
Core Async Exception Handling APIs
GrpcExceptionHandlerFunction.java, InternalGrpcExceptionHandler.java
Added ofAsync() factory method and AsyncHandler functional interface. Introduced applyAsync() default method wrapping sync API. Updated orElse() to chain both sync and async handlers. Reworked InternalGrpcExceptionHandler with new async entry points that peel/unwrap throwables, restore gRPC Status, delegate to async handlers, and fall back to defaults on null/exception.
Server-side Async Handler Integration
AbstractServerCall.java, FramedGrpcService.java
Converted synchronous exception handler invocations to async equivalents. Added CompletableFuture.handle() continuations to fall back to original Status when async handler returns null or fails. Updated close paths in AbstractServerCall and request-cancellation/content-type-error handling in FramedGrpcService.
Configuration Simplification
GrpcServiceBuilder.java
Removed unnecessary null guard around setDefaultExceptionHandler() call since handler is always assigned a non-null value.
Test Coverage
AsyncGrpcExceptionHandlerTest.java
New comprehensive test class validating async exception handlers for unary and streaming RPCs, including custom Status mapping, Metadata mutation, null-fallback behavior, handler composition via orElse(), and exceptional handler completion.

Sequence Diagram

sequenceDiagram
    participant Client
    participant ServerCall
    participant ExceptionHandler as Exception Handler
    participant DelegateHandler as Delegate Async Handler
    participant GrpcDefault as Default Handler
    
    Client->>ServerCall: Call triggers error
    ServerCall->>ExceptionHandler: handleAsync(exception)
    ExceptionHandler->>ExceptionHandler: peelAndUnwrap(throwable)
    ExceptionHandler->>ExceptionHandler: restoreStatus(status)
    ExceptionHandler->>DelegateHandler: applyAsync(status, throwable, metadata)
    alt Delegate completes with Status
        DelegateHandler-->>ExceptionHandler: Status
        ExceptionHandler-->>ServerCall: StatusAndMetadata
    else Delegate returns null or fails
        ExceptionHandler->>GrpcDefault: apply()
        GrpcDefault-->>ExceptionHandler: Default Status
        ExceptionHandler-->>ServerCall: StatusAndMetadata (fallback)
    end
    ServerCall->>Client: Close with final Status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

new feature

Suggested reviewers

  • trustin
  • jrhee17
  • minwoox
  • ikhoon

Poem

🐰 Async handlers hop with glee,
No blocking threads in our tree!
Errors handled fast and free,
Future Status sets us spry—
Event loops won't starve, oh my! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning One unacknowledged out-of-scope change noted: removal of .protoSerialization(false) from GrpcServiceBuilder.build() (issue #6670), which is unrelated to async exception handling and appears to be a rebase artifact. Restore .protoSerialization(false) in GrpcServiceBuilder.build() or clarify if this removal is intentional and document it as a separate change.
Docstring Coverage ⚠️ Warning Docstring coverage is 27.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Feature/async grpc exception handler' clearly identifies the main feature being added—async support for gRPC exception handlers—which aligns with the primary changes across all modified files.
Description check ✅ Passed The PR description comprehensively explains the motivation (event loop starvation with sync handlers), modifications (async API, internal refactoring, integration points), and results, matching the actual changeset.
Linked Issues check ✅ Passed All coding requirements from #6697 are met: async-capable handler interface added (applyAsync to GrpcExceptionHandlerFunction), internal async delegation implemented, event loop starvation addressed via non-blocking async paths, and fallback to sync handlers preserved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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
Copy Markdown
Contributor

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java (1)

962-970: ⚠️ Potential issue | 🟡 Minor

Missing mutual exclusivity check for asyncExceptionHandler.

The deprecated addExceptionMapping() methods check mutual exclusivity with exceptionHandler but not with asyncExceptionHandler. For consistency with the new asyncExceptionHandler() method which checks against exceptionMappingsBuilder, this should also check the reverse.

🛡️ Proposed fix
     `@Deprecated`
     public GrpcServiceBuilder addExceptionMapping(Class<? extends Throwable> exceptionType, Status status) {
         requireNonNull(exceptionType, "exceptionType");
         requireNonNull(status, "status");
         checkState(exceptionHandler == null,
                    "addExceptionMapping() and exceptionHandler() are mutually exclusive.");
+        checkState(asyncExceptionHandler == null,
+                   "addExceptionMapping() and asyncExceptionHandler() are mutually exclusive.");

         exceptionMappingsBuilder().on(exceptionType, status);
         return this;
     }

Apply similar changes to the other addExceptionMapping overloads at lines 983-993 and 1006-1018.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java`
around lines 962 - 970, The addExceptionMapping(...) overloads (the methods
named addExceptionMapping that currently check checkState(exceptionHandler ==
null, ...)) must also check that asyncExceptionHandler is null to enforce mutual
exclusivity with asyncExceptionHandler(); update each overload (the ones calling
exceptionMappingsBuilder().on(...)) to call checkState(asyncExceptionHandler ==
null, "addExceptionMapping() and asyncExceptionHandler() are mutually
exclusive.") in addition to the existing exceptionHandler check so both
deprecated synchronous mappings and the new asyncExceptionHandler cannot be set
together; apply the same check to all addExceptionMapping overloads referenced
in the diff.
🧹 Nitpick comments (1)
grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java (1)

194-204: Consider a more specific assertion for the failing handler test.

The current assertion assertThat(e.getStatus().getCode()).isNotNull() is quite weak since Status.Code is an enum that can never be null. Based on the implementation in AbstractServerCall, when the async handler fails exceptionally, the fallback uses Status.INTERNAL (for the close(Throwable, boolean) path) or the original status (for the close(Status, Metadata) path).

Consider asserting the specific expected status code to make the test more meaningful:

💡 Proposed improvement
     `@Test`
     void failingAsyncHandlerFallsBackToOriginalStatus() {
         final TestServiceBlockingStub client =
                 GrpcClients.newClient(serverWithFailingHandler.httpUri(),
                                       TestServiceBlockingStub.class);
         assertThatThrownBy(() -> client.unaryCall(SimpleRequest.getDefaultInstance()))
                 .isInstanceOfSatisfying(StatusRuntimeException.class, e -> {
-                    // When the async handler fails, the original status should be preserved.
-                    assertThat(e.getStatus().getCode()).isNotNull();
+                    // When the async handler fails, falls back to INTERNAL status.
+                    assertThat(e.getStatus().getCode()).isEqualTo(Status.Code.INTERNAL);
                 });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java`
around lines 194 - 204, The test failingAsyncHandlerFallsBackToOriginalStatus
currently asserts a non-null Status.Code which is meaningless; update the
assertion in
AsyncGrpcExceptionHandlerTest.failingAsyncHandlerFallsBackToOriginalStatus to
check for the precise expected Status.Code (e.g.
assertThat(e.getStatus().getCode()).isEqualTo(Status.INTERNAL) or the explicit
original status used by serverWithFailingHandler) by inspecting how
serverWithFailingHandler triggers the async failure (whether it falls back to
Status.INTERNAL via close(Throwable, boolean) or preserves the original Status
via close(Status, Metadata)) and assert that exact code on the caught
StatusRuntimeException.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java`:
- Around line 962-970: The addExceptionMapping(...) overloads (the methods named
addExceptionMapping that currently check checkState(exceptionHandler == null,
...)) must also check that asyncExceptionHandler is null to enforce mutual
exclusivity with asyncExceptionHandler(); update each overload (the ones calling
exceptionMappingsBuilder().on(...)) to call checkState(asyncExceptionHandler ==
null, "addExceptionMapping() and asyncExceptionHandler() are mutually
exclusive.") in addition to the existing exceptionHandler check so both
deprecated synchronous mappings and the new asyncExceptionHandler cannot be set
together; apply the same check to all addExceptionMapping overloads referenced
in the diff.

---

Nitpick comments:
In
`@grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java`:
- Around line 194-204: The test failingAsyncHandlerFallsBackToOriginalStatus
currently asserts a non-null Status.Code which is meaningless; update the
assertion in
AsyncGrpcExceptionHandlerTest.failingAsyncHandlerFallsBackToOriginalStatus to
check for the precise expected Status.Code (e.g.
assertThat(e.getStatus().getCode()).isEqualTo(Status.INTERNAL) or the explicit
original status used by serverWithFailingHandler) by inspecting how
serverWithFailingHandler triggers the async failure (whether it falls back to
Status.INTERNAL via close(Throwable, boolean) or preserves the original Status
via close(Status, Metadata)) and assert that exact code on the caught
StatusRuntimeException.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bb56fc9b-cc27-4859-af9c-d742fa28c8fa

📥 Commits

Reviewing files that changed from the base of the PR and between 233b5d8 and 9b6c89f.

📒 Files selected for processing (7)
  • grpc/src/main/java/com/linecorp/armeria/common/grpc/AsyncGrpcExceptionHandlerFunction.java
  • grpc/src/main/java/com/linecorp/armeria/internal/common/grpc/InternalGrpcExceptionHandler.java
  • grpc/src/main/java/com/linecorp/armeria/internal/server/grpc/AbstractServerCall.java
  • grpc/src/main/java/com/linecorp/armeria/server/grpc/FramedGrpcService.java
  • grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java
  • grpc/src/main/java/com/linecorp/armeria/server/grpc/HandlerRegistry.java
  • grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java

Copy link
Copy Markdown
Contributor

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java (1)

984-995: ⚠️ Potential issue | 🟡 Minor

Missing mutual exclusivity check for asyncExceptionHandler.

The second addExceptionMapping overload (accepting BiFunction<T, Metadata, Status>) does not enforce mutual exclusivity with asyncExceptionHandler(), unlike the other two overloads at lines 965-968 and 1013-1016. This inconsistency could allow users to accidentally combine both configuration methods.

🔧 Proposed fix
     `@Deprecated`
     public <T extends Throwable> GrpcServiceBuilder addExceptionMapping(
             Class<T> exceptionType, BiFunction<T, Metadata, Status> statusFunction) {
         requireNonNull(exceptionType, "exceptionType");
         requireNonNull(statusFunction, "statusFunction");

         checkState(exceptionHandler == null,
                    "addExceptionMapping() and exceptionMapping() are mutually exclusive.");
+        checkState(asyncExceptionHandler == null,
+                   "addExceptionMapping() and asyncExceptionHandler() are mutually exclusive.");

         exceptionMappingsBuilder().on(exceptionType, statusFunction);
         return this;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java`
around lines 984 - 995, The addExceptionMapping(Class<T>, BiFunction<T,
Metadata, Status>) overload lacks the mutual-exclusivity check against
asyncExceptionHandler()—add the same check used in the other overloads to
prevent combining exceptionMapping/addExceptionMapping with
asyncExceptionHandler; specifically, in
GrpcServiceBuilder.addExceptionMapping(...) verify asyncExceptionHandler == null
(e.g., call checkState(asyncExceptionHandler == null, "addExceptionMapping() and
asyncExceptionHandler() are mutually exclusive.")) before calling
exceptionMappingsBuilder().on(...), matching the pattern used with
exceptionHandler.
🧹 Nitpick comments (1)
grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java (1)

49-49: Consider shutting down the executor to avoid thread leaks.

The ASYNC_EXECUTOR is a single-threaded executor that is never shut down. While this might not cause issues in practice for tests, it's good hygiene to clean up executor resources. You could use @AfterAll to shut it down or use a daemon thread factory.

♻️ Option 1: Use a daemon thread factory
-    private static final Executor ASYNC_EXECUTOR = Executors.newSingleThreadExecutor();
+    private static final Executor ASYNC_EXECUTOR = Executors.newSingleThreadExecutor(r -> {
+        final Thread t = new Thread(r, "async-handler-test");
+        t.setDaemon(true);
+        return t;
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java`
at line 49, The ASYNC_EXECUTOR created by Executors.newSingleThreadExecutor() in
AsyncGrpcExceptionHandlerTest is never shut down; update the test to prevent
thread leaks by either replacing Executors.newSingleThreadExecutor() with
Executors.newSingleThreadExecutor(new
ThreadFactoryBuilder().setDaemon(true).build()) or add an `@AfterAll` static
method that calls ASYNC_EXECUTOR.shutdownNow()/shutdown() and awaits
termination; reference the ASYNC_EXECUTOR constant and the test class
AsyncGrpcExceptionHandlerTest when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java`:
- Around line 984-995: The addExceptionMapping(Class<T>, BiFunction<T, Metadata,
Status>) overload lacks the mutual-exclusivity check against
asyncExceptionHandler()—add the same check used in the other overloads to
prevent combining exceptionMapping/addExceptionMapping with
asyncExceptionHandler; specifically, in
GrpcServiceBuilder.addExceptionMapping(...) verify asyncExceptionHandler == null
(e.g., call checkState(asyncExceptionHandler == null, "addExceptionMapping() and
asyncExceptionHandler() are mutually exclusive.")) before calling
exceptionMappingsBuilder().on(...), matching the pattern used with
exceptionHandler.

---

Nitpick comments:
In
`@grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java`:
- Line 49: The ASYNC_EXECUTOR created by Executors.newSingleThreadExecutor() in
AsyncGrpcExceptionHandlerTest is never shut down; update the test to prevent
thread leaks by either replacing Executors.newSingleThreadExecutor() with
Executors.newSingleThreadExecutor(new
ThreadFactoryBuilder().setDaemon(true).build()) or add an `@AfterAll` static
method that calls ASYNC_EXECUTOR.shutdownNow()/shutdown() and awaits
termination; reference the ASYNC_EXECUTOR constant and the test class
AsyncGrpcExceptionHandlerTest when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d233532c-eed4-4081-b4de-87859763bd14

📥 Commits

Reviewing files that changed from the base of the PR and between 9b6c89f and 253019a.

📒 Files selected for processing (5)
  • grpc/src/main/java/com/linecorp/armeria/common/grpc/AsyncGrpcExceptionHandlerFunction.java
  • grpc/src/main/java/com/linecorp/armeria/internal/server/grpc/AbstractServerCall.java
  • grpc/src/main/java/com/linecorp/armeria/server/grpc/FramedGrpcService.java
  • grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java
  • grpc/src/test/java/com/linecorp/armeria/server/grpc/AsyncGrpcExceptionHandlerTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • grpc/src/main/java/com/linecorp/armeria/server/grpc/FramedGrpcService.java
  • grpc/src/main/java/com/linecorp/armeria/common/grpc/AsyncGrpcExceptionHandlerFunction.java

@Hun425
Copy link
Copy Markdown
Author

Hun425 commented Apr 10, 2026

Code review

Found 1 issue:

  1. Unrelated removal of .protoSerialization(false) from HttpJsonTranscoderBuilder in GrpcServiceBuilder.build(). This line was introduced by #6670 to force JSON serialization for transcoded gRPC requests. The default value of protoSerialization is true, so removing the explicit false silently flips the transcoding format from JSON to PROTO for users of HttpJsonTranscoding. The change is unrelated to the PR's stated purpose (async gRPC exception handling) and is not mentioned in the PR description — likely an accidental rebase/merge artifact.

final HttpJsonTranscoder transcoder =
new HttpJsonTranscoderBuilder()
.options(httpJsonTranscodingOptions)
.serviceDefinitions(grpcService.services())
.build();
if (transcoder != null) {

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@jrhee17
Copy link
Copy Markdown
Contributor

jrhee17 commented Apr 13, 2026

returns Status synchronously, making it impossible to perform async/suspend operations (e.g., i18n lookup) without runBlocking in ServerCall.close().

Just to make sure I understand the intention of this PR first, is your use-case to make async calls in the exception handler to an external component?
The reason I ask is I think users would usually want exception handler runs to be fairly deterministic - adding an async code path may break this assumption.

I was imagining a normal flow could be:

var asyncI18nMap = ...
asyncI18nMap.preload().join();

GrpcService.builder()
  .exceptionHandler((ctx, status, cause, metadata) -> {
    // use asyncI18nMap to convert
  })

I have no issue going ahead with this PR if it is a valid concern

@Hun425
Copy link
Copy Markdown
Author

Hun425 commented Apr 13, 2026

@jrhee17 Thanks for the review!

The preload pattern works for static data, but not when the
i18n store is a shared remote backend where updates are
reflected without redeployment. In that case, the translation
lookup in the exception handler is a real network I/O call
that can't be preloaded at startup.

A local cache with invalidation in front of the remote store
is one workaround, but it adds complexity specifically to work
around the sync-only constraint. An async handler would make
that unnecessary.

@jrhee17
Copy link
Copy Markdown
Contributor

jrhee17 commented Apr 14, 2026

I see, I'm not fixed on this but here are my current thoughts:

  • I agree with the separate AsyncGrpcExceptionHandlerFunction interface introduction. I don't think it makese sense to force users to also implement the sync version of Status apply(...) when only async usage is expected.

  • We can use the async version consistently within the codebase. The sync version can be modified to the async version in the builder.

AsyncGrpcExceptionHandlerFunction async = (ctx, status, cause, metadata) -> UnmodifiableFuture.completedFuture(syncHandler.apply(ctx, status, cause, metadata));

The single async version will be used throughout the internal call path

  • For cases when the async version CF fails, we can just fall back to the default DefaultGrpcExceptionHandlerFunction.INSTANCE

  • Later on, we may also add support for GrpcExceptionHandler annotations. I prefer if this is done, it is done separate from this PR. GrpcExceptionHandlerFunctionBuilder may need a separate buildAsync function.

Let me know what you think cc. @line/dx

@minwoox
Copy link
Copy Markdown
Contributor

minwoox commented Apr 14, 2026

If we introduce AsyncGrpcExceptionHandlerFunction, we also need to also add async google handler function and async annotation. How about just adding applyAsync to GrpcExceptionHandlerFunction and deprecating apply method?

@Deprecated
Status apply(RequestContext ctx, Status status, Throwable cause, Metadata metadata);

default CompletableFuture<@Nullable Status> applyAsync(RequestContext ctx, Status status, Throwable cause, Metadata metadata) {
    return CompletableFuture.completedFuture(apply(ctx, status, cause, metadata));
}

default GrpcExceptionHandlerFunction orElse(GrpcExceptionHandlerFunction next) {
    requireNonNull(next, "next");
    if (this == next) {
        return this;
    }
    return new GrpcExceptionHandlerFunction() {

        @Override
        public @Nullable Status apply(RequestContext ctx, Status status, Throwable cause,
                                      Metadata metadata) {
            return GrpcExceptionHandlerFunction.this.apply(ctx, status, cause, metadata);
        }

        @Override
        public CompletableFuture<@Nullable Status> applyAsync(RequestContext ctx, Status status,
                                                              Throwable cause,
                                                              Metadata metadata) {
            return GrpcExceptionHandlerFunction.this.applyAsync(ctx, status, cause, metadata).thenCompose(
                    newStatus -> {
                        if (newStatus != null) {
                    return CompletableFuture.completedFuture(newStatus);
                }
                return next.applyAsync(ctx, status, cause, metadata);
            });
        }
    };
}

@Hun425
Copy link
Copy Markdown
Author

Hun425 commented Apr 15, 2026

Pushed the refactor following minwoox's applyAsync() proposal. Two notes on design decisions.

1. orElse() sync chaining preserved

I kept the sync apply() chaining from the original lambda-based implementation. Your proposal only delegates to this.apply(), which would change the behavior for users calling chain.apply() directly.

// Given:
GrpcExceptionHandlerFunction h1 = (ctx, s, c, m) -> null;
GrpcExceptionHandlerFunction h2 = (ctx, s, c, m) -> Status.INTERNAL;
GrpcExceptionHandlerFunction chain = h1.orElse(h2);

// Current behavior (origin/main)
chain.apply(ctx, s, c, m); // → Status.INTERNAL (chains to h2)

// With proposal as-is
chain.apply(ctx, s, c, m); // → null (h2 never reached)

Internal code paths all go through applyAsync(), so this is only observable by external users of deprecated apply(). I went with preserving the existing behavior, but let me know if you'd prefer to drop the sync chaining.

2. Added ofAsync() static factory

Async-only users would otherwise need a dummy apply() implementation since it remains abstract.

// Without ofAsync() — boilerplate
new GrpcExceptionHandlerFunction() {

@Override
public Status apply(...) { throw new UnsupportedOperationException(); }

@Override
public CompletableFuture<Status> applyAsync(...) { return realAsyncOp(); }

}

// With ofAsync() — clean
GrpcExceptionHandlerFunction.ofAsync(
(ctx, status, cause, metadata) -> realAsyncOp())

I thought hiding the dummy apply() behind a factory would make async-only usage cleaner.

3. Fixed deadlock risk on client-side sync path(AI review)

Avoided handleAsync().join() in InternalGrpcExceptionHandler.handle() so the client event loop doesn't block when an async handler is used.
apply() is called directly, with UnsupportedOperationException from async-only handlers caught and falling back to the default.

@minwoox
Copy link
Copy Markdown
Contributor

minwoox commented Apr 17, 2026

I went with preserving the existing behavior, but let me know if you'd prefer to drop the sync chaining.

Yeah, we need to keep the existing behavior. Please go ahead. 🙇

* }</pre>
*/
@UnstableApi
static GrpcExceptionHandlerFunction ofAsync(AsyncHandler asyncHandler) {
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.

Note) Heads up - to use a functional approach, we'll still need an interface for async error handling cc. @minwoox

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.

to use a functional approach,

Yeah, we can't use the functional approach without adding the new interface. Do you think it’s worth adding the new interface?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think it's worth adding. Added it as a subinterface of GrpcExceptionHandlerFunction,
plus asyncExceptionHandler(...) on both server and client builders.

Copy link
Copy Markdown
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

I think the current changes look good overall - left some small suggestions

Comment thread grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java Outdated
@ikhoon
Copy link
Copy Markdown
Contributor

ikhoon commented May 8, 2026

@minwoox This PR is ready to review. PTAL.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 12, 2026

Codecov Report

❌ Patch coverage is 79.13043% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.00%. Comparing base (8150425) to head (42f3e81).
⚠️ Report is 445 commits behind head on main.

Files with missing lines Patch % Lines
...inecorp/armeria/server/grpc/FramedGrpcService.java 30.00% 7 Missing ⚠️
...rnal/common/grpc/InternalGrpcExceptionHandler.java 80.00% 5 Missing and 1 partial ⚠️
...rmeria/internal/client/grpc/ArmeriaClientCall.java 68.75% 5 Missing ⚠️
...meria/internal/common/grpc/HttpStreamDeframer.java 50.00% 4 Missing ⚠️
...p/armeria/internal/client/grpc/ArmeriaChannel.java 85.71% 0 Missing and 1 partial ⚠️
.../linecorp/armeria/server/grpc/UnaryServerCall.java 93.75% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6717      +/-   ##
============================================
+ Coverage     74.46%   75.00%   +0.54%     
- Complexity    22234    24757    +2523     
============================================
  Files          1963     2203     +240     
  Lines         82437    91938    +9501     
  Branches      10764    12002    +1238     
============================================
+ Hits          61385    68957    +7572     
- Misses        15918    17253    +1335     
- Partials       5134     5728     +594     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ikhoon ikhoon added this to the 1.40.0 milestone May 12, 2026
Copy link
Copy Markdown
Contributor

@minwoox minwoox left a comment

Choose a reason for hiding this comment

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

👍 👍

Copy link
Copy Markdown
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

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

Thanks, @Hun425

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add AsyncGrpcExceptionHandlerFunction for async/suspend error handling

4 participants