Skip to content

Commit cecec9b

Browse files
authored
Fix #1333 Enable using RetryHandler for 200 OK response patterns (#1334)
1 parent aabe5b9 commit cecec9b

20 files changed

+227
-88
lines changed

integration_tests/web/test_admin_analytics.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def tearDown(self):
2828
def test_sync(self):
2929
client = self.sync_client
3030

31-
response = client.admin_analytics_getFile(date="2020-10-20", type="member")
31+
response = client.admin_analytics_getFile(date="2022-10-20", type="member")
3232
self.assertTrue(isinstance(response.data, bytes))
3333
self.assertIsNotNone(response.data)
3434

@@ -44,7 +44,7 @@ def test_sync_error(self):
4444
def test_sync_public_channel(self):
4545
client = self.sync_client
4646

47-
response = client.admin_analytics_getFile(date="2020-10-20", type="public_channel")
47+
response = client.admin_analytics_getFile(date="2022-10-20", type="public_channel")
4848
self.assertTrue(isinstance(response.data, bytes))
4949
self.assertIsNotNone(response.data)
5050

@@ -59,7 +59,7 @@ def test_sync_public_channel_medata_only(self):
5959
async def test_async(self):
6060
client = self.async_client
6161

62-
response = await client.admin_analytics_getFile(date="2020-10-20", type="member")
62+
response = await client.admin_analytics_getFile(date="2022-10-20", type="member")
6363
self.assertTrue(isinstance(response.data, bytes))
6464
self.assertIsNotNone(response.data)
6565

@@ -77,7 +77,7 @@ async def test_async_error(self):
7777
async def test_async_public_channel(self):
7878
client = self.async_client
7979

80-
response = await client.admin_analytics_getFile(date="2020-10-20", type="public_channel")
80+
response = await client.admin_analytics_getFile(date="2022-10-20", type="public_channel")
8181
self.assertTrue(isinstance(response.data, bytes))
8282
self.assertIsNotNone(response.data)
8383

@@ -95,14 +95,14 @@ async def test_async_public_channel_metadata_only(self):
9595
def test_legacy(self):
9696
client = self.legacy_client
9797

98-
response = client.admin_analytics_getFile(date="2020-10-20", type="member")
98+
response = client.admin_analytics_getFile(date="2022-10-20", type="member")
9999
self.assertTrue(isinstance(response.data, bytes))
100100
self.assertIsNotNone(response.data)
101101

102102
def test_legacy_public_channel(self):
103103
client = self.legacy_client
104104

105-
response = client.admin_analytics_getFile(date="2020-10-20", type="public_channel")
105+
response = client.admin_analytics_getFile(date="2022-10-20", type="public_channel")
106106
self.assertTrue(isinstance(response.data, bytes))
107107
self.assertIsNotNone(response.data)
108108

integration_tests/web/test_issue_594.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def test_issue_594(self):
4040
external_id=external_id,
4141
external_url=external_url,
4242
title="Good Old Slack Logo",
43-
indexable_file_contents="Good Old Slack Logo",
43+
indexable_file_contents="Good Old Slack Logo".encode("utf-8"),
4444
preview_image=image,
4545
)
4646
self.assertIsNotNone(creation)
@@ -76,7 +76,7 @@ def test_no_preview_image(self):
7676
external_id=external_id,
7777
external_url=external_url,
7878
title="Slack (Wikipedia)",
79-
indexable_file_contents="Slack is a proprietary business communication platform developed by Slack Technologies.",
79+
indexable_file_contents="Slack is a proprietary business communication platform developed by Slack Technologies.".encode("utf-8"),
8080
)
8181
self.assertIsNotNone(creation)
8282

slack_sdk/web/async_internal_utils.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,24 +147,20 @@ def convert_params(values: dict) -> dict:
147147
f"body: {body}"
148148
)
149149

150-
if res.status == 429:
151-
for handler in retry_handlers:
152-
if await handler.can_retry_async(
150+
for handler in retry_handlers:
151+
if await handler.can_retry_async(
152+
state=retry_state,
153+
request=retry_request,
154+
response=retry_response,
155+
):
156+
if logger.level <= logging.DEBUG:
157+
logger.info(f"A retry handler found: {type(handler).__name__} " f"for {http_verb} {api_url}")
158+
await handler.prepare_for_next_attempt_async(
153159
state=retry_state,
154160
request=retry_request,
155161
response=retry_response,
156-
):
157-
if logger.level <= logging.DEBUG:
158-
logger.info(
159-
f"A retry handler found: {type(handler).__name__} "
160-
f"for {http_verb} {api_url} - rate_limited"
161-
)
162-
await handler.prepare_for_next_attempt_async(
163-
state=retry_state,
164-
request=retry_request,
165-
response=retry_response,
166-
)
167-
break
162+
)
163+
break
168164

169165
if retry_state.next_attempt_requested is False:
170166
response = {

slack_sdk/web/base_client.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,29 @@ def _perform_urllib_http_request(self, *, url: str, args: Dict[str, Dict[str, An
396396
try:
397397
resp = self._perform_urllib_http_request_internal(url, req)
398398
# The resp is a 200 OK response
399-
return resp
399+
if len(self.retry_handlers) > 0:
400+
retry_request = RetryHttpRequest.from_urllib_http_request(req)
401+
body_string = resp["body"] if isinstance(resp["body"], str) else None
402+
body_bytes = body_string.encode("utf-8") if body_string is not None else resp["body"]
403+
body = json.loads(body_string) if body_string is not None and body_string.startswith("{") else {}
404+
retry_response = RetryHttpResponse(
405+
status_code=resp["status"],
406+
headers=resp["headers"],
407+
body=body,
408+
data=body_bytes,
409+
)
410+
for handler in self.retry_handlers:
411+
if handler.can_retry(state=retry_state, request=retry_request, response=retry_response):
412+
if self._logger.level <= logging.DEBUG:
413+
self._logger.info(
414+
f"A retry handler found: {type(handler).__name__} for {req.method} {req.full_url}"
415+
)
416+
handler.prepare_for_next_attempt(
417+
state=retry_state, request=retry_request, response=retry_response
418+
)
419+
break
420+
if retry_state.next_attempt_requested is False:
421+
return resp
400422

401423
except HTTPError as e:
402424
# As adding new values to HTTPError#headers can be ignored, building a new dict object here

tests/slack_sdk/audit_logs/test_client_http_retry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_retries(self):
3131

3232
self.assertEqual(2, retry_handler.call_count)
3333

34-
def test_rate_limited(self):
34+
def test_ratelimited(self):
3535
client = AuditLogsClient(
3636
token="xoxp-ratelimited",
3737
base_url="http://localhost:8888/",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from typing import Optional
2+
3+
from slack_sdk.http_retry.interval_calculator import RetryIntervalCalculator
4+
from slack_sdk.http_retry.state import RetryState
5+
from slack_sdk.http_retry.request import HttpRequest
6+
from slack_sdk.http_retry.response import HttpResponse
7+
from slack_sdk.http_retry.handler import RetryHandler, default_interval_calculator
8+
9+
10+
class FatalErrorRetryHandler(RetryHandler):
11+
def __init__(
12+
self,
13+
max_retry_count: int = 1,
14+
interval_calculator: RetryIntervalCalculator = default_interval_calculator,
15+
):
16+
super().__init__(max_retry_count, interval_calculator)
17+
self.call_count = 0
18+
19+
def _can_retry(
20+
self,
21+
*,
22+
state: RetryState,
23+
request: HttpRequest,
24+
response: Optional[HttpResponse],
25+
error: Optional[Exception],
26+
) -> bool:
27+
self.call_count += 1
28+
return response is not None and response.status_code == 200 and response.body.get("error") == "fatal_error"

tests/slack_sdk/web/mock_web_api_server.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class MockHandler(SimpleHTTPRequestHandler):
2828

2929
error_html_response_body = '<!DOCTYPE html>\n<html lang="en">\n<head>\n\t<meta charset="utf-8">\n\t<title>Server Error | Slack</title>\n\t<meta name="author" content="Slack">\n\t<style></style>\n</head>\n<body>\n\t<nav class="top persistent">\n\t\t<a href="https://status.slack.com/" class="logo" data-qa="logo"></a>\n\t</nav>\n\t<div id="page">\n\t\t<div id="page_contents">\n\t\t\t<h1>\n\t\t\t\t<svg width="30px" height="27px" viewBox="0 0 60 54" class="warning_icon"><path d="" fill="#D94827"/></svg>\n\t\t\t\tServer Error\n\t\t\t</h1>\n\t\t\t<div class="card">\n\t\t\t\t<p>It seems like there’s a problem connecting to our servers, and we’re investigating the issue.</p>\n\t\t\t\t<p>Please <a href="https://status.slack.com/">check our Status page for updates</a>.</p>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t<script type="text/javascript">\n\t\tif (window.desktop) {\n\t\t\tdocument.documentElement.className = \'desktop\';\n\t\t}\n\n\t\tvar FIVE_MINS = 5 * 60 * 1000;\n\t\tvar TEN_MINS = 10 * 60 * 1000;\n\n\t\tfunction randomBetween(min, max) {\n\t\t\treturn Math.floor(Math.random() * (max - (min + 1))) + min;\n\t\t}\n\n\t\twindow.setTimeout(function () {\n\t\t\twindow.location.reload(true);\n\t\t}, randomBetween(FIVE_MINS, TEN_MINS));\n\t</script>\n</body>\n</html>'
3030

31-
state = {"rate_limited_count": 0}
31+
state = {"ratelimited_count": 0, "fatal_error_count": 0}
3232

3333
def is_valid_user_agent(self):
3434
user_agent = self.headers["User-Agent"]
@@ -98,17 +98,37 @@ def _handle(self):
9898
return
9999

100100
header = self.headers["Authorization"]
101-
if header is not None and "xoxp-" in header:
102-
pattern = str(header).split("xoxp-", 1)[1]
101+
if header is not None and ("xoxb-" in header or "xoxp-" in header):
102+
pattern = ""
103+
xoxb = str(header).split("xoxb-", 1)
104+
if len(xoxb) > 1:
105+
pattern = xoxb[1]
106+
else:
107+
xoxp = str(header).split("xoxp-", 1)
108+
pattern = xoxp[1]
109+
103110
if "remote_disconnected" in pattern:
104111
# http.client.RemoteDisconnected
105112
self.finish()
106113
return
107-
if "ratelimited" in pattern:
114+
115+
if pattern == "ratelimited" or (pattern == "ratelimited_only_once" and self.state["ratelimited_count"] == 0):
116+
self.state["ratelimited_count"] += 1
108117
self.send_response(429)
109118
self.send_header("retry-after", 1)
119+
self.send_header("content-type", "application/json;charset=utf-8")
120+
self.send_header("connection", "close")
121+
self.end_headers()
122+
self.wfile.write("""{"ok":false,"error":"ratelimited"}""".encode("utf-8"))
123+
self.wfile.close()
124+
return
125+
126+
if pattern == "fatal_error" or (pattern == "fatal_error_only_once" and self.state["fatal_error_count"] == 0):
127+
self.state["fatal_error_count"] += 1
128+
self.send_response(200)
110129
self.set_common_headers()
111-
self.wfile.write("""{"ok": false, "error": "ratelimited"}""".encode("utf-8"))
130+
self.wfile.write("""{"ok":false,"error":"fatal_error"}""".encode("utf-8"))
131+
self.wfile.close()
112132
return
113133

114134
if self.is_valid_token() and self.is_valid_user_agent():
@@ -139,22 +159,10 @@ def _handle(self):
139159
self.wfile.write("""{"ok":false}""".encode("utf-8"))
140160
return
141161

142-
if pattern == "rate_limited" or (
143-
pattern == "rate_limited_only_once" and self.state["rate_limited_count"] == 0
144-
):
145-
self.state["rate_limited_count"] += 1
146-
self.send_response(429)
147-
self.send_header("retry-after", 1)
148-
self.send_header("content-type", "application/json;charset=utf-8")
149-
self.send_header("connection", "close")
150-
self.end_headers()
151-
self.wfile.write("""{"ok":false,"error":"rate_limited"}""".encode("utf-8"))
152-
self.wfile.close()
153-
return
154-
155162
if pattern == "timeout":
156-
time.sleep(2)
163+
time.sleep(3)
157164
self.send_response(200)
165+
self.set_common_headers()
158166
self.wfile.write("""{"ok":true}""".encode("utf-8"))
159167
self.wfile.close()
160168
return
@@ -248,7 +256,7 @@ def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler):
248256

249257
def run(self):
250258
self.handler.received_requests = {}
251-
self.handler.state = {"rate_limited_count": 0}
259+
self.handler.state = {"ratelimited_count": 0}
252260
self.server = HTTPServer(("localhost", 8888), self.handler)
253261
try:
254262
self.server.serve_forever(0.05)
@@ -257,7 +265,7 @@ def run(self):
257265

258266
def stop(self):
259267
self.handler.received_requests = {}
260-
self.handler.state = {"rate_limited_count": 0}
268+
self.handler.state = {"ratelimited_count": 0}
261269
self.server.shutdown()
262270
self.join()
263271

tests/slack_sdk/web/mock_web_api_server_http_retry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ def _handle(self):
4141
self.set_common_headers()
4242
self.wfile.write("""{"ok":false}""".encode("utf-8"))
4343
return
44-
if pattern == "rate_limited":
44+
if pattern == "ratelimited":
4545
self.send_response(429)
4646
self.send_header("retry-after", 1)
4747
self.set_common_headers()
48-
self.wfile.write("""{"ok":false,"error":"rate_limited"}""".encode("utf-8"))
48+
self.wfile.write("""{"ok":false,"error":"ratelimited"}""".encode("utf-8"))
4949
self.wfile.close()
5050
return
5151

tests/slack_sdk/web/test_web_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def test_slack_api_error_is_raised_on_unsuccessful_responses(self):
8888
self.client.api_test()
8989

9090
def test_slack_api_rate_limiting_exception_returns_retry_after(self):
91-
self.client.token = "xoxb-rate_limited"
91+
self.client.token = "xoxb-ratelimited"
9292
try:
9393
self.client.api_test()
9494
except err.SlackApiError as slack_api_error:

tests/slack_sdk/web/test_web_client_http_retry.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
cleanup_mock_web_api_server,
88
setup_mock_web_api_server,
99
)
10+
from ..fatal_error_retry_handler import FatalErrorRetryHandler
1011
from ..my_retry_handler import MyRetryHandler
1112

1213

@@ -21,7 +22,7 @@ def test_remote_disconnected(self):
2122
retry_handler = MyRetryHandler(max_retry_count=2)
2223
client = WebClient(
2324
base_url="http://localhost:8888",
24-
token="xoxp-remote_disconnected",
25+
token="xoxb-remote_disconnected",
2526
team_id="T111",
2627
retry_handlers=[retry_handler],
2728
)
@@ -33,16 +34,49 @@ def test_remote_disconnected(self):
3334

3435
self.assertEqual(2, retry_handler.call_count)
3536

37+
def test_ratelimited_no_retry(self):
38+
client = WebClient(
39+
base_url="http://localhost:8888",
40+
token="xoxb-ratelimited",
41+
team_id="T111",
42+
)
43+
try:
44+
client.auth_test()
45+
self.fail("An exception is expected")
46+
except SlackApiError as e:
47+
# Just running retries; no assertions for call count so far
48+
self.assertEqual(429, e.response.status_code)
49+
3650
def test_ratelimited(self):
3751
client = WebClient(
3852
base_url="http://localhost:8888",
39-
token="xoxp-ratelimited",
53+
token="xoxb-ratelimited_only_once",
4054
team_id="T111",
4155
)
4256
client.retry_handlers.append(RateLimitErrorRetryHandler())
57+
# The auto-retry should work here
58+
client.auth_test()
59+
60+
def test_fatal_error_no_retry(self):
61+
client = WebClient(
62+
base_url="http://localhost:8888",
63+
token="xoxb-fatal_error",
64+
team_id="T111",
65+
)
4366
try:
4467
client.auth_test()
4568
self.fail("An exception is expected")
4669
except SlackApiError as e:
4770
# Just running retries; no assertions for call count so far
48-
self.assertEqual(429, e.response.status_code)
71+
self.assertEqual(200, e.response.status_code)
72+
self.assertEqual("fatal_error", e.response["error"])
73+
74+
def test_fatal_error(self):
75+
client = WebClient(
76+
base_url="http://localhost:8888",
77+
token="xoxb-fatal_error_only_once",
78+
team_id="T111",
79+
)
80+
client.retry_handlers.append(FatalErrorRetryHandler())
81+
# The auto-retry should work here
82+
client.auth_test()

0 commit comments

Comments
 (0)