Skip to content

Commit 61b44b9

Browse files
committed
Add EventErrorHandler for custom event handler error handling
Introduce a session-level EventErrorHandler functional interface that lets developers react to event handler exceptions programmatically instead of relying on the default SEVERE logging. - Add EventErrorHandler @FunctionalInterface in com.github.copilot.sdk - Add setEventErrorHandler() method on CopilotSession - Update dispatchEvent() to delegate to custom handler when set - Add 6 JUnit tests covering custom handler, null reset, error handler throwing, and event type verification - Update advanced.md and documentation.md with usage examples
1 parent a113688 commit 61b44b9

5 files changed

Lines changed: 285 additions & 4 deletions

File tree

src/main/java/com/github/copilot/sdk/CopilotSession.java

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public final class CopilotSession implements AutoCloseable {
9595
private final AtomicReference<PermissionHandler> permissionHandler = new AtomicReference<>();
9696
private final AtomicReference<UserInputHandler> userInputHandler = new AtomicReference<>();
9797
private final AtomicReference<SessionHooks> hooksHandler = new AtomicReference<>();
98+
private volatile EventErrorHandler eventErrorHandler;
9899

99100
/**
100101
* Creates a new session with the given ID and RPC client.
@@ -152,6 +153,38 @@ public String getWorkspacePath() {
152153
return workspacePath;
153154
}
154155

156+
/**
157+
* Sets a custom error handler for exceptions thrown by event handlers.
158+
* <p>
159+
* When an event handler registered via {@link #on(Consumer)} or
160+
* {@link #on(Class, Consumer)} throws an exception during event dispatch, the
161+
* error handler is invoked instead of the default behavior (logging at
162+
* {@link Level#SEVERE}).
163+
*
164+
* <p>
165+
* If the error handler itself throws an exception, that exception is silently
166+
* caught and logged to prevent cascading failures.
167+
*
168+
* <p>
169+
* <b>Example:</b>
170+
*
171+
* <pre>{@code
172+
* session.setEventErrorHandler((event, exception) -> {
173+
* metrics.increment("handler.errors");
174+
* logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
175+
* });
176+
* }</pre>
177+
*
178+
* @param handler
179+
* the error handler, or {@code null} to restore default logging
180+
* behavior
181+
* @see EventErrorHandler
182+
* @since 1.0.8
183+
*/
184+
public void setEventErrorHandler(EventErrorHandler handler) {
185+
this.eventErrorHandler = handler;
186+
}
187+
155188
/**
156189
* Sends a simple text message to the Copilot session.
157190
* <p>
@@ -377,18 +410,31 @@ public <T extends AbstractSessionEvent> Closeable on(Class<T> eventType, Consume
377410
* <p>
378411
* This is called internally when events are received from the server. Each
379412
* handler is invoked in its own try/catch block so that an exception thrown by
380-
* one handler does not prevent subsequent handlers from executing. Exceptions
381-
* are logged at {@link Level#SEVERE}.
413+
* one handler does not prevent subsequent handlers from executing.
414+
* <p>
415+
* If a custom {@link EventErrorHandler} has been set via
416+
* {@link #setEventErrorHandler(EventErrorHandler)}, it is called with the event
417+
* and exception. Otherwise, exceptions are logged at {@link Level#SEVERE}.
382418
*
383419
* @param event
384420
* the event to dispatch
421+
* @see #setEventErrorHandler(EventErrorHandler)
385422
*/
386423
void dispatchEvent(AbstractSessionEvent event) {
387424
for (Consumer<AbstractSessionEvent> handler : eventHandlers) {
388425
try {
389426
handler.accept(event);
390427
} catch (Exception e) {
391-
LOG.log(Level.SEVERE, "Error in event handler", e);
428+
EventErrorHandler errorHandler = this.eventErrorHandler;
429+
if (errorHandler != null) {
430+
try {
431+
errorHandler.handleError(event, e);
432+
} catch (Exception errorHandlerException) {
433+
LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException);
434+
}
435+
} else {
436+
LOG.log(Level.SEVERE, "Error in event handler", e);
437+
}
392438
}
393439
}
394440
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.sdk;
6+
7+
import com.github.copilot.sdk.events.AbstractSessionEvent;
8+
9+
/**
10+
* A handler for errors thrown by event handlers during event dispatch.
11+
* <p>
12+
* When an event handler registered via
13+
* {@link CopilotSession#on(java.util.function.Consumer)} or
14+
* {@link CopilotSession#on(Class, java.util.function.Consumer)} throws an
15+
* exception, the {@code EventErrorHandler} is invoked with the event that was
16+
* being dispatched and the exception that was thrown.
17+
*
18+
* <p>
19+
* The default behavior logs errors at {@link java.util.logging.Level#SEVERE}.
20+
* You can override this to integrate with your own logging, metrics, or
21+
* error-reporting systems:
22+
*
23+
* <pre>{@code
24+
* session.setEventErrorHandler((event, exception) -> {
25+
* metrics.increment("handler.errors");
26+
* logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
27+
* });
28+
* }</pre>
29+
*
30+
* <p>
31+
* If the error handler itself throws an exception, that exception is silently
32+
* caught and logged to prevent cascading failures.
33+
*
34+
* @see CopilotSession#setEventErrorHandler(EventErrorHandler)
35+
* @since 1.0.8
36+
*/
37+
@FunctionalInterface
38+
public interface EventErrorHandler {
39+
40+
/**
41+
* Called when an event handler throws an exception during event dispatch.
42+
*
43+
* @param event
44+
* the event that was being dispatched when the error occurred
45+
* @param exception
46+
* the exception thrown by the event handler
47+
*/
48+
void handleError(AbstractSessionEvent event, Exception exception);
49+
}

src/site/markdown/advanced.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,3 +442,28 @@ session.on(AssistantMessageEvent.class, msg -> {
442442
> Go, and Python Copilot SDKs, which all catch handler errors per-handler. The
443443
> .NET SDK is an exception — handler errors propagate there and can prevent
444444
> subsequent handlers from running.
445+
446+
### Custom Event Error Handler
447+
448+
By default, handler exceptions are logged at `SEVERE` level using
449+
`java.util.logging`. You can replace this with a custom
450+
`EventErrorHandler` to integrate with your own logging, metrics, or
451+
error-reporting systems:
452+
453+
```java
454+
session.setEventErrorHandler((event, exception) -> {
455+
metrics.increment("handler.errors");
456+
logger.error("Handler failed on {}: {}",
457+
event.getType(), exception.getMessage());
458+
});
459+
```
460+
461+
The error handler receives both the event that was being dispatched and the
462+
exception that was thrown. If the error handler itself throws, that exception
463+
is silently caught and logged to prevent cascading failures.
464+
465+
Pass `null` to restore the default logging behavior:
466+
467+
```java
468+
session.setEventErrorHandler(null);
469+
```

src/site/markdown/documentation.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ For more control, subscribe to events and use `send()`:
5959

6060
> **Exception isolation:** If a handler throws an exception, the SDK logs the
6161
> error and continues dispatching to remaining handlers. One misbehaving handler
62-
> will never prevent others from executing.
62+
> will never prevent others from executing. You can customize error handling with
63+
> `session.setEventErrorHandler()` — see the
64+
> [Advanced Usage](advanced.html#Custom_Event_Error_Handler) guide.
6365
6466
```java
6567
var done = new CompletableFuture<Void>();

src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,165 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception {
376376
}
377377

378378
// Helper methods to dispatch events using reflection
379+
// ====================================================================
380+
// EventErrorHandler tests
381+
// ====================================================================
382+
383+
@Test
384+
void testDefaultErrorHandlerLogsException() {
385+
// Without a custom error handler, exceptions should be logged (no crash)
386+
Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName());
387+
Level originalLevel = sessionLogger.getLevel();
388+
sessionLogger.setLevel(Level.OFF);
389+
390+
try {
391+
session.on(AssistantMessageEvent.class, msg -> {
392+
throw new RuntimeException("boom");
393+
});
394+
395+
assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test")));
396+
} finally {
397+
sessionLogger.setLevel(originalLevel);
398+
}
399+
}
400+
401+
@Test
402+
void testCustomEventErrorHandlerReceivesEventAndException() {
403+
var capturedEvents = new ArrayList<AbstractSessionEvent>();
404+
var capturedExceptions = new ArrayList<Exception>();
405+
406+
session.setEventErrorHandler((event, exception) -> {
407+
capturedEvents.add(event);
408+
capturedExceptions.add(exception);
409+
});
410+
411+
var thrownException = new RuntimeException("test error");
412+
session.on(AssistantMessageEvent.class, msg -> {
413+
throw thrownException;
414+
});
415+
416+
var event = createAssistantMessageEvent("Hello");
417+
dispatchEvent(event);
418+
419+
assertEquals(1, capturedEvents.size());
420+
assertSame(event, capturedEvents.get(0));
421+
assertEquals(1, capturedExceptions.size());
422+
assertSame(thrownException, capturedExceptions.get(0));
423+
}
424+
425+
@Test
426+
void testCustomErrorHandlerReplacesDefaultLogging() {
427+
var errorCount = new AtomicInteger(0);
428+
429+
session.setEventErrorHandler((event, exception) -> {
430+
errorCount.incrementAndGet();
431+
});
432+
433+
session.on(AssistantMessageEvent.class, msg -> {
434+
throw new RuntimeException("error 1");
435+
});
436+
session.on(AssistantMessageEvent.class, msg -> {
437+
throw new RuntimeException("error 2");
438+
});
439+
440+
dispatchEvent(createAssistantMessageEvent("Test"));
441+
442+
// Both handler errors should be reported to the custom error handler
443+
assertEquals(2, errorCount.get());
444+
}
445+
446+
@Test
447+
void testErrorHandlerItselfThrowingDoesNotBreakDispatch() {
448+
var received = new ArrayList<String>();
449+
450+
Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName());
451+
Level originalLevel = sessionLogger.getLevel();
452+
sessionLogger.setLevel(Level.OFF);
453+
454+
try {
455+
session.setEventErrorHandler((event, exception) -> {
456+
throw new RuntimeException("error handler also broke");
457+
});
458+
459+
// First handler throws
460+
session.on(AssistantMessageEvent.class, msg -> {
461+
throw new RuntimeException("handler error");
462+
});
463+
464+
// Second handler should still execute
465+
session.on(AssistantMessageEvent.class, msg -> {
466+
received.add(msg.getData().getContent());
467+
});
468+
469+
assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Still works")));
470+
assertEquals(1, received.size());
471+
assertEquals("Still works", received.get(0));
472+
} finally {
473+
sessionLogger.setLevel(originalLevel);
474+
}
475+
}
476+
477+
@Test
478+
void testSetEventErrorHandlerToNullRestoresDefaultBehavior() {
479+
var errorCount = new AtomicInteger(0);
480+
481+
// Set custom handler
482+
session.setEventErrorHandler((event, exception) -> {
483+
errorCount.incrementAndGet();
484+
});
485+
486+
session.on(AssistantMessageEvent.class, msg -> {
487+
throw new RuntimeException("error");
488+
});
489+
490+
dispatchEvent(createAssistantMessageEvent("Test1"));
491+
assertEquals(1, errorCount.get());
492+
493+
// Reset to null (restore default logging)
494+
session.setEventErrorHandler(null);
495+
496+
// Suppress default logging for the next dispatch
497+
Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName());
498+
Level originalLevel = sessionLogger.getLevel();
499+
sessionLogger.setLevel(Level.OFF);
500+
501+
try {
502+
dispatchEvent(createAssistantMessageEvent("Test2"));
503+
} finally {
504+
sessionLogger.setLevel(originalLevel);
505+
}
506+
507+
// Custom handler should NOT have been called again
508+
assertEquals(1, errorCount.get());
509+
}
510+
511+
@Test
512+
void testErrorHandlerReceivesCorrectEventType() {
513+
var capturedEvents = new ArrayList<AbstractSessionEvent>();
514+
515+
session.setEventErrorHandler((event, exception) -> {
516+
capturedEvents.add(event);
517+
});
518+
519+
session.on(event -> {
520+
throw new RuntimeException("always fails");
521+
});
522+
523+
var msgEvent = createAssistantMessageEvent("msg");
524+
var idleEvent = createSessionIdleEvent();
525+
526+
dispatchEvent(msgEvent);
527+
dispatchEvent(idleEvent);
528+
529+
assertEquals(2, capturedEvents.size());
530+
assertInstanceOf(AssistantMessageEvent.class, capturedEvents.get(0));
531+
assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1));
532+
}
533+
534+
// ====================================================================
535+
// Helper methods
536+
// ====================================================================
537+
379538
private void dispatchEvent(AbstractSessionEvent event) {
380539
try {
381540
Method dispatchMethod = CopilotSession.class.getDeclaredMethod("dispatchEvent", AbstractSessionEvent.class);

0 commit comments

Comments
 (0)