Repository: slackapi/python-slack-sdk (python)

Issue:
SlackApiError: 'dict' object has no attribute 'status_code'
`slack_sdk.web` reports the `'dict' object has no attribute 'status_code'` error when attempting to retrieve the SlackApiError's `status_code`.


### Reproducible in:

```bash
$ pip list | grep slack
slack-sdk          3.23.0
$ pip freeze | grep slack
-e git+ssh://git@github.com/python-slack-sdk.git@9967dc0a206d974f7ef2c09dd6c49f70b98ecf1e#egg=slack_sdk
$ python --version
Python 3.9.6
$ sw_vers
ProductName:		macOS
ProductVersion:		13.6.1
BuildVersion:		22G313
```

#### Steps to reproduce:

1. When catching SlackApiError, decide the different process logics by retrieving and checking the returned response's `status_code`, similar to the example code of checking ratelimit `status_code 429` in [slack.dev ](https://slack.dev/python-slack-sdk/web/index.html#rate-limits) website. The code looks like below:
```python
try:
    response = send_slack_message(channel, message)
except SlackApiError as e:
    if err_status_code := e.response.status_code >= 500:
        print(f"SlackApiError returned status code: {err_status_code}")
    else:
        <omitted>
```
2. Wait for Slack Service Unavailable e.g. 503 to trigger the except processing in the above code


### Expected result:

When SlackApiError happens, the code (by calling `slack_sdk.web`) can retrieve `status_code` from SlackApiError's [response](https://github.com/slackapi/python-slack-sdk/blob/v3.23.0/slack_sdk/web/slack_response.py#L72) attribute successfully, w/o throwing out any further error.


### Actual result:

When a real Slack service outage happened, the following AttributeError `'dict' object has no attribute 'status_code'` was throw out unexpectedly:
```bash
slack_sdk.errors.SlackApiError: Received a response in a non-JSON format: <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"><html><head><title>503 Service Unavailable</title>...
The server responded with: {'status': 503, 'headers': {'date': '<day>, <date> <time>', 'server': 'Apache', 'content-length': '299', 'content-type': 'text/html; charset=iso-8859-1', 'x-envoy-upstream-service-time': '1', 'x-backend': 'main_normal main_canary_with_overflow main_control_with_overflow', 'x-server': 'slack-www-hhvm-main-iad-vdno', 'via': 'envoy-www-iad-rplytdoy, envoy-edge-iad-zbjxhisv', 'x-slack-shared-secret-outcome': 'no-match', 'x-edge-backend': 'envoy-www', 'x-slack-edge-shared-secret-outcome': 'no-match', 'connection': 'close'}, 'body': '<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">\n<html><head>\n<title>503 Service Unavailable</title>\n</head><body>\n<h1>Service Unavailable</h1>\n<p>The server is temporarily unable to service your\nrequest due to maintenance downtime or capacity\nproblems. Please try again later.</p>\n</body></html>\n'}

During handling of the above exception, another exception occurred:

AttributeError: 'dict' object has no attribute 'status_code'
```

### Probable cause:

When encountering API return's json decoding error (that's the case of 500 error), the response is a dict (returned by [_perform_urllib_http_request()](https://github.com/slackapi/python-slack-sdk/blob/v3.23.0/slack_sdk/web/base_client.py#L323)) - this implementation in [slack_sdk.web.base_client](https://github.com/slackapi/python-slack-sdk/blob/v3.23.0/slack_sdk/web/base_client.py#L294-L302) module must have been there for a long time, even before v3.0 :

```python
            response = self._perform_urllib_http_request(url=url, args=request_args)
            response_body = response.get("body", None)  # skipcq: PTC-W0039
            response_body_data: Optional[Union[dict, bytes]] = response_body
            if response_body is not None and not isinstance(response_body, bytes):
                try:
                    response_body_data = json.loads(response["body"])
                except json.decoder.JSONDecodeError:
                    message = _build_unexpected_body_error_message(response.get("body", ""))
                    raise err.SlackApiError(message, response)
```

But SlackApiError's [response](https://github.com/slackapi/python-slack-sdk/blob/v3.23.0/slack_sdk/errors/__init__.py#L22) attribute should be the type of [SlackResponse](https://github.com/slackapi/python-slack-sdk/blob/v3.23.0/slack_sdk/web/slack_response.py#L12) instead of dict that doesn't have `status_code`:
```python
class SlackApiError(SlackClientError):
    """Error raised when Slack does not send the expected response.

    Attributes:
        response (SlackResponse): The SlackResponse object containing all of the data sent back from the API.
```

### Probable solution:

There are probably several solutions. To make the code easy to reference (always provide `status_code` return for whatever SlackApiError) and consistent (use only one type of response for SlackApiError and one entry to SlackApiError), we can re-assemble the response body data by leveraging the message output from [_build_unexpected_body_error_message()](https://github.com/slackapi/python-slack-sdk/blob/v3.23.0/slack_sdk/web/internal_utils.py#L293) (that's a str), with one line change only:
```python
            except json.decoder.JSONDecodeError:
                message = _build_unexpected_body_error_message(response.get("body", ""))
                # raise err.SlackApiError(message, response)
                response_body_data = {'ok': False, 'error': message}
```
Then this payload will be sent to SlackResponse's [validate()](https://github.com/slackapi/python-slack-sdk/blob/v3.23.0/slack_sdk/web/base_client.py#L309), that will trigger SlackApiError eventually:
```python
            return SlackResponse(
                client=self,
                http_verb="POST",  # you can use POST method for all the Web APIs
                api_url=url,
                req_args=request_args,
                data=response_body_data,
                headers=dict(response["headers"]),
                status_code=response["status"],
            ).validate()
```

Seeking the feedback and suggestions - I can raise a PR if the above proposed fix is acceptable.

### Mock test:

Using the above proposed solution, mock test passed like below - no longer trigger another unexpected AttributeError, also provide status_code access and consistent error message:
```python
>>> with patch('slack_sdk.WebClient._perform_urllib_http_request') as mock_urllib:
...     http_error_response = HTTPError("https://example.com/api/data", 503, "503 Service Unavailable", None, BytesIO(b'<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"><html><head><title>503 Service Unavailable</title>'))
...     mock_urllib.side_effect = http_error_response
...     try:
...         ret = client.chat_postMessage(channel="chan", text="hello")
...     except HTTPError as e:
...         response_headers = {'date': '<day>, <date> <time>', 'server': 'Apache', 'content-length': '299', 'content-type': 'text/html; charset=iso-8859-1'}
...         response = {"status": e.code, "headers": response_headers, "body": e.read().decode("utf-8")}
...         print(f"The type of returned response: {type(response)}")
...         response_body = response.get("body", None)
...         response_body_data: Optional[Union[dict, bytes]] = response_body
...         if response_body is not None and not isinstance(response_body, bytes):
...             try:
...                 response_body_data = json.loads(response["body"])
...             except json.decoder.JSONDecodeError:
...                 message = _build_unexpected_body_error_message(response.get("body", ""))
...                 # raise SlackApiError(message, response)
...                 response_body_data = {'ok': False, 'error': message}
...         try:
...             SlackResponse(
...                 client=client,
...                 http_verb="POST",  # you can use POST method for all the Web APIs
...                 api_url=url,
...                 req_args=request_args,
...                 data=response_body_data,
...                 headers=dict(response["headers"]),
...                 status_code=response["status"],
...             ).validate()
...         except SlackApiError as e:
...             print(f"SlackApiError attribute response's status_code: {e.response.status_code}")
...             print(f"SlackApiError attribute response's data: {e.response.data}")
...
The type of returned response: <class 'dict'>
SlackApiError attribute response's status_code: 503
SlackApiError attribute response's data: {'ok': False, 'error': 'Received a response in a non-JSON format: <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"><html><head><title>503 Service Unavailable</title>'}
>>>
```

Pull request description:
Fix #1421 Update SlackApiError exception handling for web client
## Summary

This pull request resolves #1421, also updated unit tests and README.

### Category (place an `x` in each of the `[ ]`)

- [x] **slack_sdk.web.WebClient (sync/async)** (Web API client)
- [ ] **slack_sdk.webhook.WebhookClient (sync/async)** (Incoming Webhook, response_url sender)
- [ ] **slack_sdk.socket_mode** (Socket Mode client)
- [ ] **slack_sdk.signature** (Request Signature Verifier)
- [ ] **slack_sdk.oauth** (OAuth Flow Utilities)
- [ ] **slack_sdk.models** (UI component builders)
- [ ] **slack_sdk.scim** (SCIM API client)
- [ ] **slack_sdk.audit_logs** (Audit Logs API client)
- [ ] **slack_sdk.rtm_v2** (RTM client)
- [ ] `/docs-src` (Documents, have you run `./scripts/docs.sh`?)
- [ ] `/docs-src-v2` (Documents, have you run `./scripts/docs-v2.sh`?)
- [ ] `/tutorial` (PythOnBoardingBot tutorial)
- [ ] `tests`/`integration_tests` (Automated tests for this library)

## Requirements (place an `x` in each `[ ]`)

- [x] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/python-slack-sdk/blob/main/.github/contributing.md) and have done my best effort to follow them.
- [x] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).
- [x] I've run `python3 -m venv .venv && source .venv/bin/activate && ./scripts/run_validation.sh` after making the changes.


Patch:
diff --git a/README.md b/README.md
index 5da4406..03a48d1 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,9 @@ except SlackApiError as e:
     assert e.response["ok"] is False
     assert e.response["error"]  # str like 'invalid_auth', 'channel_not_found'
     print(f"Got an error: {e.response['error']}")
+    # Also receive a corresponding status_code
+    assert isinstance(e.response.status_code, int)
+    print(f"Received a response status_code: {e.response.status_code}")
 ```
 
 Here we also ensure that the response back from Slack is a successful one and that the message is the one we sent by using the `assert` statement.
diff --git a/slack_sdk/web/base_client.py b/slack_sdk/web/base_client.py
index 7dc5624..5bef287 100644
--- a/slack_sdk/web/base_client.py
+++ b/slack_sdk/web/base_client.py
@@ -19,7 +19,6 @@ from urllib.error import HTTPError
 from urllib.parse import urlencode
 from urllib.request import Request, urlopen, OpenerDirector, ProxyHandler, HTTPSHandler
 
-import slack_sdk.errors as err
 from slack_sdk.errors import SlackRequestError
 from .deprecation import show_deprecation_warning_if_any
 from .internal_utils import (
@@ -299,7 +298,8 @@ class BaseClient:
                     response_body_data = json.loads(response["body"])
                 except json.decoder.JSONDecodeError:
                     message = _build_unexpected_body_error_message(response.get("body", ""))
-                    raise err.SlackApiError(message, response)
+                    self._logger.error(f"Failed to decode Slack API response: {message}")
+                    response_body_data = {"ok": False, "error": message}
 
             all_params: Dict[str, Any] = copy.copy(body_params) if body_params is not None else {}
             if query_params:


Test patch:
diff --git a/tests/slack_sdk/web/test_web_client.py b/tests/slack_sdk/web/test_web_client.py
index 46810de..024915a 100644
--- a/tests/slack_sdk/web/test_web_client.py
+++ b/tests/slack_sdk/web/test_web_client.py
@@ -150,7 +150,7 @@ class TestWebClient(unittest.TestCase):
             self.fail("SlackApiError expected here")
         except err.SlackApiError as e:
             self.assertTrue(
-                str(e).startswith("Received a response in a non-JSON format: <!DOCTYPE HTML PUBLIC"),
+                str(e).startswith("The request to the Slack API failed. (url: http://"),
                 e,
             )
 
diff --git a/tests/slack_sdk/web/test_web_client_http_retry_server_error.py b/tests/slack_sdk/web/test_web_client_http_retry_server_error.py
index 51e5e58..ab7408f 100644
--- a/tests/slack_sdk/web/test_web_client_http_retry_server_error.py
+++ b/tests/slack_sdk/web/test_web_client_http_retry_server_error.py
@@ -51,7 +51,16 @@ class TestWebClient_HttpRetry_ServerError(unittest.TestCase):
             client.users_list(token="xoxb-error_html_response")
             self.fail("SlackApiError expected here")
         except err.SlackApiError as e:
-            self.assertTrue(str(e).startswith("Received a response in a non-JSON format: "), e)
+            self.assertTrue(
+                str(e).startswith("The request to the Slack API failed. (url: http://"),
+                e,
+            )
+            self.assertIsInstance(e.response.status_code, int)
+            self.assertFalse(e.response["ok"])
+            self.assertTrue(
+                e.response["error"].startswith("Received a response in a non-JSON format: <!DOCTYPE "),
+                e.response["error"],
+            )
 
         self.assertEqual(2, retry_handlers[0].call_count)
 
diff --git a/tests/slack_sdk/web/test_web_client_issue_829.py b/tests/slack_sdk/web/test_web_client_issue_829.py
index 327fb37..346d6a3 100644
--- a/tests/slack_sdk/web/test_web_client_issue_829.py
+++ b/tests/slack_sdk/web/test_web_client_issue_829.py
@@ -21,4 +21,13 @@ class TestWebClient_Issue_829(unittest.TestCase):
             client.users_list(token="xoxb-error_html_response")
             self.fail("SlackApiError expected here")
         except err.SlackApiError as e:
-            self.assertTrue(str(e).startswith("Received a response in a non-JSON format: "), e)
+            self.assertTrue(
+                str(e).startswith("The request to the Slack API failed. (url: http://"),
+                e,
+            )
+            self.assertIsInstance(e.response.status_code, int)
+            self.assertFalse(e.response["ok"])
+            self.assertTrue(
+                e.response["error"].startswith("Received a response in a non-JSON format: <!DOCTYPE "),
+                e.response["error"],
+            )