From 0bcf998241427eac0900e8f05b4946f445b37275 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2020 00:13:19 +0900 Subject: [PATCH] Fix #31 by adding app.error handler --- .travis.yml | 2 +- samples/app.py | 6 + scripts/install_all_and_run_tests.sh | 21 +++ scripts/run_pytype.sh | 6 + scripts/run_tests.sh | 6 +- slack_bolt/app/app.py | 71 ++++++-- slack_bolt/app/async_app.py | 77 +++++++-- slack_bolt/context/async_context.py | 6 + slack_bolt/context/base_context.py | 5 - slack_bolt/context/context.py | 6 + .../listener/async_listener_error_handler.py | 70 ++++++++ slack_bolt/listener/listener_error_handler.py | 61 +++++++ .../test_error_handler.py | 151 ++++++++++++++++++ tests/scenario_tests/test_error_handler.py | 141 ++++++++++++++++ 14 files changed, 592 insertions(+), 37 deletions(-) create mode 100755 scripts/install_all_and_run_tests.sh create mode 100755 scripts/run_pytype.sh create mode 100644 slack_bolt/listener/async_listener_error_handler.py create mode 100644 slack_bolt/listener/listener_error_handler.py create mode 100644 tests/async_scenario_tests/test_error_handler.py create mode 100644 tests/scenario_tests/test_error_handler.py diff --git a/.travis.yml b/.travis.yml index 28302ab00..974b6b9b5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,4 +20,4 @@ script: # run all tests just in case - travis_retry python setup.py test # Run pytype only for Python 3.8 - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install -e ".[adapter]"; pytype slack_bolt/; fi + - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install -e ".[adapter]" && pytype slack_bolt/; fi diff --git a/samples/app.py b/samples/app.py index 0cddb3829..54739356a 100644 --- a/samples/app.py +++ b/samples/app.py @@ -26,6 +26,12 @@ def event_test(payload, say, logger): say("What's up?") +@app.error +def global_error_handler(error, payload, logger): + logger.exception(error) + logger.info(payload) + + if __name__ == "__main__": app.start(3000) diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh new file mode 100755 index 000000000..a1dbb8e2c --- /dev/null +++ b/scripts/install_all_and_run_tests.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Run all the tests or a single test +# all: ./scripts/install_all_and_run_tests.sh +# single: ./scripts/install_all_and_run_tests.sh tests/scenario_tests/test_app.py + +script_dir=`dirname $0` +cd ${script_dir}/.. + +test_target="$1" + +if [[ $test_target != "" ]] +then + pip install -e ".[testing]" && \ + black slack_bolt/ tests/ && \ + pytest $1 +else + pip install -e ".[testing]" && \ + black slack_bolt/ tests/ && \ + pytest && \ + pytype slack_bolt/ +fi diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh new file mode 100755 index 000000000..f5424bbf0 --- /dev/null +++ b/scripts/run_pytype.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ./scripts/run_pytype.sh + +script_dir=$(dirname $0) +cd ${script_dir}/.. +pip install -e ".[adapter]" && pytype slack_bolt/ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 9d75ed908..6a5579008 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -10,12 +10,10 @@ test_target="$1" if [[ $test_target != "" ]] then - pip install -e ".[testing]" && \ - black slack_bolt/ tests/ && \ + black slack_bolt/ tests/ && \ pytest $1 else - pip install -e ".[testing]" && \ - black slack_bolt/ tests/ && \ + black slack_bolt/ tests/ && \ pytest && \ pytype slack_bolt/ fi diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index efc95291d..f1ec88393 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -15,6 +15,11 @@ from slack_bolt.error import BoltError from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_error_handler import ( + ListenerErrorHandler, + DefaultListenerErrorHandler, + CustomListenerErrorHandler, +) from slack_bolt.listener_matcher import CustomListenerMatcher from slack_bolt.listener_matcher import builtins as builtin_matchers from slack_bolt.listener_matcher.listener_matcher import ListenerMatcher @@ -178,6 +183,9 @@ def __init__( self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] self._listener_executor = ThreadPoolExecutor(max_workers=5) # TODO: shutdown + self._listener_error_handler = DefaultListenerErrorHandler( + logger=self._framework_logger + ) self._process_before_response = process_before_response self._init_middleware_list_done = False @@ -236,6 +244,10 @@ def installation_store(self) -> Optional[InstallationStore]: def oauth_state_store(self) -> Optional[OAuthStateStore]: return self._oauth_state_store + @property + def listener_error_handler(self) -> ListenerErrorHandler: + return self._listener_error_handler + # ------------------------- # standalone server @@ -299,13 +311,24 @@ def run_listener( ack = request.context.ack starting_time = time.time() if self._process_before_response: - returned_value = listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and listener.auto_acknowledgement: - ack() # automatic ack() call if the call is not yet done + try: + returned_value = listener.run_ack_function( + request=request, response=response + ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and listener.auto_acknowledgement: + ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + self._listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response if response is not None: self._debug_log_completion(starting_time, response) @@ -316,13 +339,22 @@ def run_listener( else: # start the listener function asynchronously def run_ack_function_asynchronously(): + nonlocal ack, request, response try: listener.run_ack_function(request=request, response=response) except Exception as e: - # TODO: error handler - self._framework_logger.exception( - f"Failed to run listener function (error: {e})" + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + + self._listener_error_handler.handle( + error=e, request=request, response=response, ) + ack.response = response self._listener_executor.submit(run_ack_function_asynchronously) @@ -334,12 +366,17 @@ def run_ack_function_asynchronously(): while ack.response is None and time.time() - starting_time <= 3: time.sleep(0.01) + if response is None and ack.response is None: + self._framework_logger.warning(f"{listener_name} didn't call ack()") + return None + if response is None and ack.response is not None: response = ack.response self._debug_log_completion(starting_time, response) return response - else: - self._framework_logger.warning(f"{listener_name} didn't call ack()") + + if response is not None: + return response # None for both means no ack() in the listener return None @@ -365,6 +402,16 @@ def middleware(self, *args): CustomMiddleware(app_name=self.name, func=func) ) + # ------------------------- + # global error handler + + def error(self, *args): + if len(args) > 0: + func = args[0] + self._listener_error_handler = CustomListenerErrorHandler( + logger=self._framework_logger, func=func, + ) + # ------------------------- # events diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 2d0798143..570d213b1 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -15,8 +15,14 @@ from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.error import BoltError from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener +from slack_bolt.listener.async_listener_error_handler import ( + AsyncDefaultListenerErrorHandler, + AsyncListenerErrorHandler, + AsyncCustomListenerErrorHandler, +) from slack_bolt.listener_matcher import builtins as builtin_matchers from slack_bolt.listener_matcher.async_listener_matcher import ( AsyncListenerMatcher, @@ -197,6 +203,9 @@ def __init__( self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] + self._async_listener_error_handler = AsyncDefaultListenerErrorHandler( + logger=self._framework_logger + ) self._process_before_response = process_before_response self._init_middleware_list_done = False @@ -254,6 +263,10 @@ def installation_store(self) -> Optional[AsyncInstallationStore]: def oauth_state_store(self) -> Optional[AsyncOAuthStateStore]: return self._async_oauth_state_store + @property + def listener_error_handler(self) -> AsyncListenerErrorHandler: + return self._async_listener_error_handler + # ------------------------- # standalone server @@ -323,13 +336,24 @@ async def run_async_listener( ack = request.context.ack starting_time = time.time() if self._process_before_response: - returned_value = await async_listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and async_listener.auto_acknowledgement: - await ack() # automatic ack() call if the call is not yet done + try: + returned_value = await async_listener.run_ack_function( + request=request, response=response + ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and async_listener.auto_acknowledgement: + await ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + await self._async_listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response if response is not None: self._debug_log_completion(starting_time, response) @@ -345,20 +369,28 @@ async def run_async_listener( # start the listener function asynchronously # NOTE: intentionally async def run_ack_function_asynchronously( - request: AsyncBoltRequest, response: BoltResponse, + ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, ): try: await async_listener.run_ack_function( request=request, response=response ) except Exception as e: - # TODO: error handler - self._framework_logger.exception( - f"Failed to run listener function (error: {e})" + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + + await self._async_listener_error_handler.handle( + error=e, request=request, response=response, ) + ack.response = response _f: Future = asyncio.ensure_future( - run_ack_function_asynchronously(request, response) + run_ack_function_asynchronously(ack, request, response) ) self._framework_logger.debug(f"Async listener: {listener_name} started..") @@ -366,12 +398,17 @@ async def run_ack_function_asynchronously( while ack.response is None and time.time() - starting_time <= 3: await asyncio.sleep(0.01) - if ack.response is not None: + if response is None and ack.response is None: + self._framework_logger.warning(f"{listener_name} didn't call ack()") + return None + + if response is None and ack.response is not None: response = ack.response self._debug_log_completion(starting_time, response) return response - else: - self._framework_logger.warning(f"{listener_name} didn't call ack()") + + if response is not None: + return response # None for both means no ack() in the listener return None @@ -397,6 +434,16 @@ def middleware(self, *args): AsyncCustomMiddleware(app_name=self.name, func=func) ) + # ------------------------- + # global error handler + + def error(self, *args): + if len(args) > 0: + func = args[0] + self._async_listener_error_handler = AsyncCustomListenerErrorHandler( + logger=self._framework_logger, func=func, + ) + # ------------------------- # events diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 2c28cec28..b9a5b7873 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -1,5 +1,7 @@ from typing import Optional +from slack_sdk.web.async_client import AsyncWebClient + from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond.async_respond import AsyncRespond @@ -7,6 +9,10 @@ class AsyncBoltContext(BaseContext): + @property + def client(self) -> Optional[AsyncWebClient]: + return self.get("client", None) + @property def ack(self) -> AsyncAck: if "ack" not in self: diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 77a806105..839ff0826 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -2,7 +2,6 @@ from typing import Optional, Tuple from slack_bolt.auth import AuthorizationResult -from slack_sdk import WebClient class BaseContext(dict): @@ -18,10 +17,6 @@ def logger(self) -> Logger: def token(self) -> Optional[str]: return self.get("token", None) - @property - def client(self) -> Optional[WebClient]: - return self.get("client", None) - @property def enterprise_id(self) -> Optional[str]: return self.get("enterprise_id", None) diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 19fb1cbfb..b428b5c9a 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -1,6 +1,8 @@ # pytype: skip-file from typing import Optional +from slack_sdk import WebClient + from slack_bolt.context.ack import Ack from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond import Respond @@ -8,6 +10,10 @@ class BoltContext(BaseContext): + @property + def client(self) -> Optional[WebClient]: + return self.get("client", None) + @property def ack(self) -> Ack: if "ack" not in self: diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py new file mode 100644 index 000000000..7416e2a96 --- /dev/null +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -0,0 +1,70 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +class AsyncListenerErrorHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + raise NotImplementedError() + + +class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + all_available_args = { + "logger": self.logger, + "error": error, + "client": request.context.client, + "req": request, + "request": request, + "resp": response, + "response": response, + "context": request.context, + "payload": request.payload, + "body": request.payload, + "say": request.context.say, + "respond": request.context.respond, + } + kwargs: Dict[str, Any] = { # type: ignore + k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore + } + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + await self.func(**kwargs) + + +class AsyncDefaultListenerErrorHandler(AsyncListenerErrorHandler): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + message = f"Failed to run listener function (error: {error})" + self.logger.exception(message) diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py new file mode 100644 index 000000000..f283bd4c4 --- /dev/null +++ b/slack_bolt/listener/listener_error_handler.py @@ -0,0 +1,61 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Optional + +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse + + +class ListenerErrorHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + ) -> None: + raise NotImplementedError() + + +class CustomListenerErrorHandler(ListenerErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., None]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + def handle( + self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + ): + all_available_args = { + "logger": self.logger, + "error": error, + "client": request.context.client, + "req": request, + "request": request, + "resp": response, + "response": response, + "context": request.context, + "payload": request.payload, + "body": request.payload, + "say": request.context.say, + "respond": request.context.respond, + } + kwargs: Dict[str, Any] = { # type: ignore + k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore + } + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + self.func(**kwargs) + + +class DefaultListenerErrorHandler(ListenerErrorHandler): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + ): + message = f"Failed to run listener function (error: {error})" + self.logger.exception(message) diff --git a/tests/async_scenario_tests/test_error_handler.py b/tests/async_scenario_tests/test_error_handler.py new file mode 100644 index 000000000..4fb0a7fab --- /dev/null +++ b/tests/async_scenario_tests/test_error_handler.py @@ -0,0 +1,151 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncErrorHandler: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + # ---------------- + # utilities + # ---------------- + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + payload = { + "type": "block_actions", + "user": {"id": "W111",}, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": {"id": "T111",}, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(payload))}" + timestamp = str(int(time())) + return AsyncBoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + # ---------------- + # tests + # ---------------- + + @pytest.mark.asyncio + async def test_default(self): + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + + @pytest.mark.asyncio + async def test_custom(self): + async def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + 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) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + @pytest.mark.asyncio + async def test_process_before_response_default(self): + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + + @pytest.mark.asyncio + async def test_process_before_response_custom(self): + async def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py new file mode 100644 index 000000000..1f6d1f526 --- /dev/null +++ b/tests/scenario_tests/test_error_handler.py @@ -0,0 +1,141 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestErrorHandler: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + # ---------------- + # utilities + # ---------------- + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + payload = { + "type": "block_actions", + "user": {"id": "W111",}, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": {"id": "T111",}, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(payload))}" + timestamp = str(int(time())) + return BoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + # ---------------- + # tests + # ---------------- + + def test_default(self): + def failing_listener(): + raise Exception("Something wrong!") + + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + + def test_custom(self): + def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + 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) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + def test_process_before_response_default(self): + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + + def test_process_before_response_custom(self): + def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"]