From 605fcc8c1ea0f6c0cd909fac6d25f48261a85f3a Mon Sep 17 00:00:00 2001 From: Sahil Jagtap Date: Thu, 8 Oct 2026 17:49:13 -0400 Subject: [PATCH] fix: error handlers that take args: Args crashed instead of running Error handlers, listener start/completion handlers and middleware error handlers build their kwargs with next_keys_required=False, which removed next/next_ from the available args. Args.__init__ requires next, so a handler declared as (args: Args) raised TypeError and never ran. The original error was then lost. - Keep next/next_ available for building Args and only skip injecting them as direct kwargs when there is no middleware chain - Add an error field to Args and AsyncArgs so error handlers can read the exception through args - Add sync and async tests for an error handler that takes args --- slack_bolt/kwargs_injection/args.py | 4 ++++ slack_bolt/kwargs_injection/async_args.py | 4 ++++ slack_bolt/kwargs_injection/async_utils.py | 11 +++++----- slack_bolt/kwargs_injection/utils.py | 11 +++++----- tests/scenario_tests/test_error_handler.py | 22 ++++++++++++++++++- .../test_error_handler.py | 22 +++++++++++++++++++ 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 1c215743d..5d9274374 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -111,6 +111,8 @@ def handle_buttons(args): """`next()` utility function, which tells the middleware chain that it can continue with the next one""" next_: Callable[[], None] """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions""" + error: Optional[Exception] + """The exception raised by a listener or middleware; only set in error handlers""" def __init__( self, @@ -144,6 +146,7 @@ def __init__( # the naming conflict with the built-in one affects # only the internals of this method next: Callable[[], None], + error: Optional[Exception] = None, **kwargs, # noqa ): self.logger: logging.Logger = logger @@ -177,3 +180,4 @@ def __init__( self.next: Callable[[], None] = next self.next_: Callable[[], None] = next + self.error: Optional[Exception] = error diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 3c1806b06..1d0897259 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -110,6 +110,8 @@ async def handle_buttons(args): """`next()` utility function, which tells the middleware chain that it can continue with the next one""" next_: Callable[[], Awaitable[None]] """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions""" + error: Optional[Exception] + """The exception raised by a listener or middleware; only set in error handlers""" def __init__( self, @@ -140,6 +142,7 @@ def __init__( save_thread_context: Optional[AsyncSaveThreadContext] = None, say_stream: Optional[AsyncSayStream] = None, next: Callable[[], Awaitable[None]], + error: Optional[Exception] = None, **kwargs, # noqa ): self.logger: Logger = logger @@ -173,3 +176,4 @@ def __init__( self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next + self.error: Optional[Exception] = error diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 246fd10c9..04f8b8f58 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -65,10 +65,6 @@ def build_async_required_kwargs( # error handler "error": error, # Exception } - if not next_keys_required: - all_available_args.pop("next") - all_available_args.pop("next_") - all_available_args["payload"] = ( all_available_args["options"] or all_available_args["shortcut"] @@ -98,7 +94,12 @@ def build_async_required_kwargs( # We are sure that we should skip manipulating this arg required_arg_names.pop(0) - kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in required_arg_names} + # next/next_ are only injectable where a middleware chain exists. + # They stay in all_available_args so an `args` object can still be built. + injectable_names = set(required_arg_names) + if not next_keys_required: + injectable_names -= {"next", "next_"} + kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in injectable_names} found_arg_names = kwargs.keys() for name in required_arg_names: if name == "args": diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 218fbeb6e..7c3fd4126 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -64,10 +64,6 @@ def build_required_kwargs( # error handler "error": error, # Exception } - if not next_keys_required: - all_available_args.pop("next") - all_available_args.pop("next_") - all_available_args["payload"] = ( all_available_args["options"] or all_available_args["shortcut"] @@ -97,7 +93,12 @@ def build_required_kwargs( # We are sure that we should skip manipulating this arg required_arg_names.pop(0) - kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in required_arg_names} + # next/next_ are only injectable where a middleware chain exists. + # They stay in all_available_args so an `args` object can still be built. + injectable_names = set(required_arg_names) + if not next_keys_required: + injectable_names -= {"next", "next_"} + kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in injectable_names} found_arg_names = kwargs.keys() for name in required_arg_names: if name == "args": diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py index 856b85ddf..1f963de82 100644 --- a/tests/scenario_tests/test_error_handler.py +++ b/tests/scenario_tests/test_error_handler.py @@ -5,7 +5,7 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest, BoltResponse +from slack_bolt import Args, BoltRequest, BoltResponse from slack_bolt.app import App from slack_bolt.error import BoltUnhandledRequestError from tests.mock_web_api_server import ( @@ -153,6 +153,26 @@ def failing_listener(): assert response.status == 500 assert response.headers["x-test-result"] == ["1"] + def test_custom_with_args(self): + called = [] + + def error_handler(args: Args): + assert isinstance(args.error, Exception) + args.response.headers["x-test-result"] = ["1"] + called.append(True) + + def failing_listener(): + raise Exception("Something wrong!") + + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.error(error_handler) + app.action("a")(failing_listener) + + response = app.dispatch(self.build_valid_request()) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + assert called == [True] + def test_unhandled_errors(self): app = App( client=self.web_client, diff --git a/tests/scenario_tests_async/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py index fb0b9ddae..6d392d37e 100644 --- a/tests/scenario_tests_async/test_error_handler.py +++ b/tests/scenario_tests_async/test_error_handler.py @@ -7,6 +7,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_bolt import BoltResponse +from slack_bolt.kwargs_injection.async_args import AsyncArgs from slack_bolt.async_app import AsyncApp from slack_bolt.error import BoltUnhandledRequestError from slack_bolt.request.async_request import AsyncBoltRequest @@ -163,6 +164,27 @@ async def failing_listener(): assert response.status == 500 assert response.headers["x-test-result"] == ["1"] + @pytest.mark.asyncio + async def test_custom_with_args(self): + called = [] + + async def error_handler(args: AsyncArgs): + assert isinstance(args.error, Exception) + args.response.headers["x-test-result"] = ["1"] + called.append(True) + + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.error(error_handler) + app.action("a")(failing_listener) + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + assert called == [True] + @pytest.mark.asyncio async def test_unhandled_errors(self): app = AsyncApp(