Skip to content

Commit 684611c

Browse files
committed
Fix #273 Enable developers to customize the way to handle unmatched requests
1 parent fd10ebd commit 684611c

File tree

8 files changed

+334
-16
lines changed

8 files changed

+334
-16
lines changed

slack_bolt/app/app.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
InstallationStoreAuthorize,
1818
CallableAuthorize,
1919
)
20-
from slack_bolt.error import BoltError
20+
from slack_bolt.error import BoltError, BoltUnhandledRequestError
2121
from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner
2222
from slack_bolt.listener.builtins import TokenRevocationListeners
2323
from slack_bolt.listener.custom_listener import CustomListener
@@ -50,6 +50,7 @@
5050
debug_return_listener_middleware_response,
5151
info_default_oauth_settings_loaded,
5252
error_installation_store_required_for_builtin_listeners,
53+
warning_unhandled_by_global_middleware,
5354
)
5455
from slack_bolt.middleware import (
5556
Middleware,
@@ -85,6 +86,8 @@ def __init__(
8586
name: Optional[str] = None,
8687
# Set True when you run this app on a FaaS platform
8788
process_before_response: bool = False,
89+
# Set True if you want to handle an unhandled request as an exception
90+
raise_error_for_unhandled_request: bool = False,
8891
# Basic Information > Credentials > Signing Secret
8992
signing_secret: Optional[str] = None,
9093
# for single-workspace apps
@@ -94,7 +97,7 @@ def __init__(
9497
# for multi-workspace apps
9598
authorize: Optional[Callable[..., AuthorizeResult]] = None,
9699
installation_store: Optional[InstallationStore] = None,
97-
# for v1.0.x compatibility
100+
# for either only bot scope usage or v1.0.x compatibility
98101
installation_store_bot_only: Optional[bool] = None,
99102
# for the OAuth flow
100103
oauth_settings: Optional[OAuthSettings] = None,
@@ -132,6 +135,9 @@ def message_hello(message, say):
132135
logger: The custom logger that can be used in this app.
133136
name: The application name that will be used in logging. If absent, the source file name will be used.
134137
process_before_response: True if this app runs on Function as a Service. (Default: False)
138+
raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
139+
and use @app.error listeners instead of
140+
the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
135141
signing_secret: The Signing Secret value used for verifying requests from Slack.
136142
token: The bot/user access token required only for single-workspace app.
137143
token_verification_enabled: Verifies the validity of the given token if True.
@@ -154,6 +160,7 @@ def message_hello(message, say):
154160
"SLACK_VERIFICATION_TOKEN", None
155161
)
156162
self._framework_logger = logger or get_bolt_logger(App)
163+
self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
157164

158165
self._token: Optional[str] = token
159166

@@ -411,9 +418,26 @@ def middleware_next():
411418
resp = middleware.process(req=req, resp=resp, next=middleware_next)
412419
if not middleware_state["next_called"]:
413420
if resp is None:
414-
return BoltResponse(
421+
# next() method was not called without providing the response to return to Slack
422+
# This should not be an intentional handling in usual use cases.
423+
resp = BoltResponse(
415424
status=404, body={"error": "no next() calls in middleware"}
416425
)
426+
if self._raise_error_for_unhandled_request is True:
427+
self._listener_runner.listener_error_handler.handle(
428+
error=BoltUnhandledRequestError(
429+
request=req,
430+
current_response=resp,
431+
last_global_middleware_name=middleware.name,
432+
),
433+
request=req,
434+
response=resp,
435+
)
436+
return resp
437+
self._framework_logger.warning(
438+
warning_unhandled_by_global_middleware(middleware.name, req)
439+
)
440+
return resp
417441
return resp
418442

419443
for listener in self._listeners:
@@ -452,8 +476,27 @@ def middleware_next():
452476
if listener_response is not None:
453477
return listener_response
454478

479+
if resp is None:
480+
resp = BoltResponse(status=404, body={"error": "unhandled request"})
481+
if self._raise_error_for_unhandled_request is True:
482+
self._listener_runner.listener_error_handler.handle(
483+
error=BoltUnhandledRequestError(
484+
request=req,
485+
current_response=resp,
486+
),
487+
request=req,
488+
response=resp,
489+
)
490+
return resp
491+
return self._handle_unmatched_requests(req, resp)
492+
493+
def _handle_unmatched_requests(
494+
self, req: BoltRequest, resp: BoltResponse
495+
) -> BoltResponse:
496+
# TODO: provide more info like suggestion of listeners
497+
# e.g., You can handle this type of message with @app.event("app_mention")
455498
self._framework_logger.warning(warning_unhandled_request(req))
456-
return BoltResponse(status=404, body={"error": "unhandled request"})
499+
return resp
457500

458501
# -------------------------
459502
# middleware
@@ -563,7 +606,7 @@ def step(
563606
# global error handler
564607

565608
def error(
566-
self, func: Callable[..., None]
609+
self, func: Callable[..., Optional[BoltResponse]]
567610
) -> Optional[Callable[..., Optional[BoltResponse]]]:
568611
"""Updates the global error handler. This method can be used as either a decorator or a method.
569612

slack_bolt/app/async_app.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
AsyncCallableAuthorize,
3434
AsyncInstallationStoreAuthorize,
3535
)
36-
from slack_bolt.error import BoltError
36+
from slack_bolt.error import BoltError, BoltUnhandledRequestError
3737
from slack_bolt.logger.messages import (
3838
warning_client_prioritized_and_token_skipped,
3939
warning_token_skipped,
@@ -51,6 +51,7 @@
5151
debug_return_listener_middleware_response,
5252
info_default_oauth_settings_loaded,
5353
error_installation_store_required_for_builtin_listeners,
54+
warning_unhandled_by_global_middleware,
5455
)
5556
from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner
5657
from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener
@@ -96,13 +97,16 @@ def __init__(
9697
name: Optional[str] = None,
9798
# Set True when you run this app on a FaaS platform
9899
process_before_response: bool = False,
100+
# Set True if you want to handle an unhandled request as an exception
101+
raise_error_for_unhandled_request: bool = False,
99102
# Basic Information > Credentials > Signing Secret
100103
signing_secret: Optional[str] = None,
101104
# for single-workspace apps
102105
token: Optional[str] = None,
103106
client: Optional[AsyncWebClient] = None,
104107
# for multi-workspace apps
105108
installation_store: Optional[AsyncInstallationStore] = None,
109+
# for either only bot scope usage or v1.0.x compatibility
106110
installation_store_bot_only: Optional[bool] = None,
107111
authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None,
108112
# for the OAuth flow
@@ -141,6 +145,9 @@ async def message_hello(message, say): # async function
141145
logger: The custom logger that can be used in this app.
142146
name: The application name that will be used in logging. If absent, the source file name will be used.
143147
process_before_response: True if this app runs on Function as a Service. (Default: False)
148+
raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
149+
and use @app.error listeners instead of
150+
the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
144151
signing_secret: The Signing Secret value used for verifying requests from Slack.
145152
token: The bot/user access token required only for single-workspace app.
146153
client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app.
@@ -161,6 +168,7 @@ async def message_hello(message, say): # async function
161168
"SLACK_VERIFICATION_TOKEN", None
162169
)
163170
self._framework_logger = logger or get_bolt_logger(AsyncApp)
171+
self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
164172

165173
self._token: Optional[str] = token
166174

@@ -464,9 +472,26 @@ async def async_middleware_next():
464472
)
465473
if not middleware_state["next_called"]:
466474
if resp is None:
467-
return BoltResponse(
475+
# next() method was not called without providing the response to return to Slack
476+
# This should not be an intentional handling in usual use cases.
477+
resp = BoltResponse(
468478
status=404, body={"error": "no next() calls in middleware"}
469479
)
480+
if self._raise_error_for_unhandled_request is True:
481+
await self._async_listener_runner.listener_error_handler.handle(
482+
error=BoltUnhandledRequestError(
483+
request=req,
484+
current_response=resp,
485+
last_global_middleware_name=middleware.name,
486+
),
487+
request=req,
488+
response=resp,
489+
)
490+
return resp
491+
self._framework_logger.warning(
492+
warning_unhandled_by_global_middleware(middleware.name, req)
493+
)
494+
return resp
470495
return resp
471496

472497
for listener in self._async_listeners:
@@ -508,8 +533,27 @@ async def async_middleware_next():
508533
if listener_response is not None:
509534
return listener_response
510535

536+
if resp is None:
537+
resp = BoltResponse(status=404, body={"error": "unhandled request"})
538+
if self._raise_error_for_unhandled_request is True:
539+
await self._async_listener_runner.listener_error_handler.handle(
540+
error=BoltUnhandledRequestError(
541+
request=req,
542+
current_response=resp,
543+
),
544+
request=req,
545+
response=resp,
546+
)
547+
return resp
548+
return self._handle_unmatched_requests(req, resp)
549+
550+
def _handle_unmatched_requests(
551+
self, req: AsyncBoltRequest, resp: BoltResponse
552+
) -> BoltResponse:
553+
# TODO: provide more info like suggestion of listeners
554+
# e.g., You can handle this type of message with @app.event("app_mention")
511555
self._framework_logger.warning(warning_unhandled_request(req))
512-
return BoltResponse(status=404, body={"error": "unhandled request"})
556+
return resp
513557

514558
# -------------------------
515559
# middleware
@@ -623,8 +667,8 @@ def step(
623667
# global error handler
624668

625669
def error(
626-
self, func: Callable[..., Awaitable[None]]
627-
) -> Callable[..., Awaitable[None]]:
670+
self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
671+
) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
628672
"""Updates the global error handler. This method can be used as either a decorator or a method.
629673
630674
# Use this method as a decorator
@@ -642,6 +686,9 @@ async def custom_error_handler(error, body, logger):
642686
func: The function that is supposed to be executed
643687
when getting an unhandled error in Bolt app.
644688
"""
689+
if not inspect.iscoroutinefunction(func):
690+
name = get_name_for_callable(func)
691+
raise BoltError(error_listener_function_must_be_coro_func(name))
645692
self._async_listener_runner.listener_error_handler = (
646693
AsyncCustomListenerErrorHandler(
647694
logger=self._framework_logger,

slack_bolt/error/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
"""Bolt specific error types."""
2+
from typing import Optional, Union
23

34

45
class BoltError(Exception):
56
"""General class in a Bolt app"""
7+
8+
9+
class BoltUnhandledRequestError(BoltError):
10+
request: "BoltRequest" # type: ignore
11+
body: dict
12+
current_response: Optional["BoltResponse"] # type: ignore
13+
last_global_middleware_name: Optional[str]
14+
15+
def __init__( # type: ignore
16+
self,
17+
*,
18+
request: Union["BoltRequest", "AsyncBoltRequest"], # type: ignore
19+
current_response: Optional["BoltResponse"], # type: ignore
20+
last_global_middleware_name: Optional[str] = None,
21+
):
22+
self.request = request
23+
self.body = request.body if request is not None else {}
24+
self.current_response = current_response
25+
self.last_global_middleware_name = last_global_middleware_name

slack_bolt/listener/async_listener_error_handler.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ async def handle(
3333

3434

3535
class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler):
36-
def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
36+
def __init__(
37+
self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]
38+
):
3739
self.func = func
3840
self.logger = logger
3941
self.arg_names = inspect.getfullargspec(func).args
@@ -55,7 +57,13 @@ async def handle(
5557
arg_names=self.arg_names,
5658
logger=self.logger,
5759
)
58-
await self.func(**kwargs)
60+
returned_response = await self.func(**kwargs)
61+
if returned_response is not None and isinstance(
62+
returned_response, BoltResponse
63+
):
64+
response.status = returned_response.status
65+
response.headers = returned_response.headers
66+
response.body = returned_response.body
5967

6068

6169
class AsyncDefaultListenerErrorHandler(AsyncListenerErrorHandler):

slack_bolt/listener/listener_error_handler.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def handle(
3131

3232

3333
class CustomListenerErrorHandler(ListenerErrorHandler):
34-
def __init__(self, logger: Logger, func: Callable[..., None]):
34+
def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]):
3535
self.func = func
3636
self.logger = logger
3737
self.arg_names = inspect.getfullargspec(func).args
@@ -53,7 +53,13 @@ def handle(
5353
arg_names=self.arg_names,
5454
logger=self.logger,
5555
)
56-
self.func(**kwargs)
56+
returned_response = self.func(**kwargs)
57+
if returned_response is not None and isinstance(
58+
returned_response, BoltResponse
59+
):
60+
response.status = returned_response.status
61+
response.headers = returned_response.headers
62+
response.body = returned_response.body
5763

5864

5965
class DefaultListenerErrorHandler(ListenerErrorHandler):

slack_bolt/logger/messages.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,18 @@ def warning_installation_store_conflicts() -> str:
8585
return "As you gave both `installation_store` and `oauth_settings`/`auth_flow`, the top level one is unused."
8686

8787

88-
def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str: # type: ignore
88+
def warning_unhandled_by_global_middleware( # type: ignore
89+
name: str, req: Union[BoltRequest, "AsyncBoltRequest"] # type: ignore
90+
) -> str: # type: ignore
91+
return (
92+
f"A global middleware ({name}) skipped calling `next()` "
93+
f"without providing a response for the request ({req.body})"
94+
)
95+
96+
97+
def warning_unhandled_request( # type: ignore
98+
req: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore
99+
) -> str: # type: ignore
89100
return f"Unhandled request ({req.body})"
90101

91102

0 commit comments

Comments
 (0)