Skip to content

Commit ee0b76b

Browse files
committed
fix: parse HTTP-date Retry-After at Graph call sites
1 parent 665ec55 commit ee0b76b

4 files changed

Lines changed: 63 additions & 37 deletions

File tree

src/entrabot/graph_helpers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import requests
1515

16+
from entrabot.tools.rate_limit import parse_retry_after
1617
from entrabot.url_safety import _is_graph_url
1718

1819
GRAPH_BETA = "https://graph.microsoft.com/beta"
@@ -84,7 +85,7 @@ def graph_request(
8485
resp = requests.request(method, url, headers=headers, json=json_body, timeout=timeout)
8586

8687
if retry and resp.status_code in _RETRYABLE:
87-
wait = int(resp.headers.get("Retry-After", "10"))
88+
wait = parse_retry_after(resp.headers.get("Retry-After"), default=10)
8889
print(f" Graph API returned {resp.status_code}; retrying in {wait}s…")
8990
time.sleep(wait)
9091
resp = requests.request(method, url, headers=headers, json=json_body, timeout=timeout)

src/entrabot/tools/teams.py

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from entrabot.graph_helpers import odata_escape
3939
from entrabot.platform import get_credential_store
4040
from entrabot.tools.audit import log_event
41-
from entrabot.tools.rate_limit import RetryOn429Transport
41+
from entrabot.tools.rate_limit import RetryOn429Transport, parse_retry_after
4242
from entrabot.url_safety import _is_graph_url
4343

4444
logger = logging.getLogger("entrabot.tools.teams")
@@ -336,9 +336,7 @@ async def create_one_on_one_chat(
336336
agent_member: dict = {
337337
"@odata.type": "#microsoft.graph.aadUserConversationMember",
338338
"roles": ["owner"],
339-
340-
f"https://graph.microsoft.com/v1.0/users('{_agent_bind_value}')"
341-
),
339+
"[email protected]": (f"https://graph.microsoft.com/v1.0/users('{_agent_bind_value}')"),
342340
}
343341

344342
payload = {
@@ -371,7 +369,7 @@ async def create_one_on_one_chat(
371369
logger.error("400 creating 1:1 chat: %s", error_msg)
372370
raise ValueError(f"Graph API rejected chat creation: {error_msg}")
373371
if resp.status_code == 429:
374-
retry_after = int(resp.headers.get("Retry-After", "60"))
372+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
375373
raise RateLimitError(retry_after)
376374
resp.raise_for_status()
377375

@@ -499,7 +497,7 @@ async def create_or_find_chat(
499497
"their actual UPN. Check ENTRABOT_HUMAN_USER_MAILS in .env."
500498
)
501499
if resp.status_code == 429:
502-
retry_after = int(resp.headers.get("Retry-After", "60"))
500+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
503501
raise RateLimitError(retry_after)
504502
resp.raise_for_status()
505503

@@ -635,9 +633,7 @@ async def add_member(
635633
(
636634
s
637635
for s in sponsors
638-
if any(
639-
e.lower() == requester_lower for e in s.email_identifiers()
640-
)
636+
if any(e.lower() == requester_lower for e in s.email_identifiers())
641637
),
642638
None,
643639
)
@@ -711,13 +707,8 @@ async def add_member(
711707
# Gate 2 (defense-in-depth): existing Graph membership check.
712708
members = await _fetch_chat_members_for_gate(chat_id)
713709
matched_user_id = matched_sponsor.user_id.lower()
714-
if not any(
715-
(m.get("user_id") or "").strip().lower() == matched_user_id
716-
for m in members
717-
):
718-
err = RequesterNotInChatError(
719-
requester=requester_email, chat_id=chat_id
720-
)
710+
if not any((m.get("user_id") or "").strip().lower() == matched_user_id for m in members):
711+
err = RequesterNotInChatError(requester=requester_email, chat_id=chat_id)
721712
log_event(
722713
action="teams.add_member",
723714
resource=audit_resource,
@@ -771,11 +762,9 @@ async def add_member(
771762
error_msg = resp.text or "Not found"
772763
raise ChatNotFound(f"Could not add member: {error_msg}")
773764
if resp.status_code == 401:
774-
raise TokenExpiredError(
775-
"Agent User token expired — re-acquire via three-hop flow"
776-
)
765+
raise TokenExpiredError("Agent User token expired — re-acquire via three-hop flow")
777766
if resp.status_code == 429:
778-
retry_after = int(resp.headers.get("Retry-After", "60"))
767+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
779768
raise RateLimitError(retry_after)
780769
resp.raise_for_status()
781770

@@ -842,7 +831,7 @@ async def list_members(
842831
if resp.status_code == 401:
843832
raise TokenExpiredError("Agent User token expired — re-acquire via three-hop flow")
844833
if resp.status_code == 429:
845-
retry_after = int(resp.headers.get("Retry-After", "60"))
834+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
846835
raise RateLimitError(retry_after)
847836
resp.raise_for_status()
848837

@@ -929,7 +918,7 @@ async def send(
929918
if resp.status_code == 401:
930919
raise TokenExpiredError("Token expired — re-acquire")
931920
if resp.status_code == 429:
932-
retry_after = int(resp.headers.get("Retry-After", "60"))
921+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
933922
raise RateLimitError(retry_after)
934923
if resp.status_code == 404:
935924
raise ChatNotFound(f"Chat {chat_id} not found")
@@ -985,7 +974,7 @@ async def post_thinking_placeholder(
985974
if resp.status_code == 404:
986975
raise ChatNotFound(f"Chat {chat_id} not found")
987976
if resp.status_code == 429:
988-
retry_after = int(resp.headers.get("Retry-After", "60"))
977+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
989978
raise RateLimitError(retry_after)
990979
resp.raise_for_status()
991980

@@ -1039,7 +1028,7 @@ async def update_placeholder(
10391028
if resp.status_code == 401:
10401029
raise TokenExpiredError("Token expired — re-acquire")
10411030
if resp.status_code == 429:
1042-
retry_after = int(resp.headers.get("Retry-After", "60"))
1031+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
10431032
raise RateLimitError(retry_after)
10441033
if 200 <= resp.status_code < 300:
10451034
return {"message_id": placeholder_id, "mode": "edit"}
@@ -1098,7 +1087,7 @@ async def resolve_placeholder(
10981087
if resp.status_code == 401:
10991088
raise TokenExpiredError("Token expired — re-acquire")
11001089
if resp.status_code == 429:
1101-
retry_after = int(resp.headers.get("Retry-After", "60"))
1090+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
11021091
raise RateLimitError(retry_after)
11031092
if 200 <= resp.status_code < 300:
11041093
return {"message_id": placeholder_id, "mode": "edit"}
@@ -1121,7 +1110,7 @@ async def resolve_placeholder(
11211110
if sd_resp.status_code == 401:
11221111
raise TokenExpiredError("Token expired — re-acquire")
11231112
if sd_resp.status_code == 429:
1124-
retry_after = int(sd_resp.headers.get("Retry-After", "60"))
1113+
retry_after = parse_retry_after(sd_resp.headers.get("Retry-After"), default=60)
11251114
raise RateLimitError(retry_after)
11261115
delete_ok = 200 <= sd_resp.status_code < 300
11271116
if not delete_ok:
@@ -1198,7 +1187,7 @@ async def delete_chat_message(
11981187
if resp.status_code == 401:
11991188
raise TokenExpiredError("Token expired — re-acquire")
12001189
if resp.status_code == 429:
1201-
retry_after = int(resp.headers.get("Retry-After", "60"))
1190+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
12021191
raise RateLimitError(retry_after)
12031192
if 200 <= resp.status_code < 300:
12041193
return True
@@ -1234,7 +1223,7 @@ async def fetch_hosted_image(*, token: str, url: str) -> bytes | None:
12341223
if resp.status_code == 404:
12351224
return None
12361225
if resp.status_code == 429:
1237-
retry_after = int(resp.headers.get("Retry-After", "60"))
1226+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
12381227
raise RateLimitError(retry_after)
12391228
resp.raise_for_status()
12401229
return resp.content
@@ -1324,7 +1313,7 @@ async def fetch_message(
13241313
if resp.status_code == 401:
13251314
raise TokenExpiredError("Token expired — re-acquire")
13261315
if resp.status_code == 429:
1327-
retry_after = int(resp.headers.get("Retry-After", "60"))
1316+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
13281317
raise RateLimitError(retry_after)
13291318
if not (200 <= resp.status_code < 300):
13301319
return None
@@ -1366,7 +1355,7 @@ async def read(
13661355
if resp.status_code == 404:
13671356
raise ChatNotFound(f"Chat {chat_id} not found")
13681357
if resp.status_code == 429:
1369-
retry_after = int(resp.headers.get("Retry-After", "60"))
1358+
retry_after = parse_retry_after(resp.headers.get("Retry-After"), default=60)
13701359
raise RateLimitError(retry_after)
13711360
resp.raise_for_status()
13721361

tests/test_graph_helpers.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ def test_retry_on_429(self):
9898
assert mock_requests.request.call_count == 2
9999
mock_time.sleep.assert_called_once_with(1)
100100

101+
def test_retry_on_429_with_http_date_retry_after(self):
102+
from entrabot.graph_helpers import graph_request
103+
104+
throttled = _resp(429, headers={"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"})
105+
ok = _resp(200, {"value": []})
106+
with (
107+
patch("entrabot.graph_helpers.requests") as mock_requests,
108+
patch("entrabot.graph_helpers.time") as mock_time,
109+
):
110+
mock_requests.request.side_effect = [throttled, ok]
111+
result = graph_request("GET", "/users", "tok")
112+
113+
assert result.status_code == 200
114+
assert mock_requests.request.call_count == 2
115+
mock_time.sleep.assert_called_once_with(0)
116+
101117
def test_retry_on_503(self):
102118
from entrabot.graph_helpers import graph_request
103119

@@ -260,9 +276,10 @@ def test_accepts_commercial_graph_next_link_and_fetches_second_page(self):
260276

261277
assert result == [{"id": "a"}, {"id": "b"}]
262278
assert mock_requests.request.call_args_list[1].args == ("GET", next_link)
263-
assert mock_requests.request.call_args_list[1].kwargs["headers"][
264-
"Authorization"
265-
] == "Bearer tok"
279+
assert (
280+
mock_requests.request.call_args_list[1].kwargs["headers"]["Authorization"]
281+
== "Bearer tok"
282+
)
266283

267284
def test_error_raises(self):
268285
from entrabot.graph_helpers import graph_collection_values

tests/tools/test_teams.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
AgentIDNotAvailable,
2121
ChatNotFound,
2222
MessageTooLong,
23+
RateLimitError,
2324
TeamsNotLicensed,
2425
TokenExchangeError,
2526
TokenExpiredError,
@@ -765,9 +766,7 @@ def _sponsor_gate_patches(*, chat_id: str = "19:[email protected]"):
765766
patch(
766767
"entrabot.tools.teams._fetch_chat_members_for_gate",
767768
new=AsyncMock(
768-
return_value=[
769-
{"user_id": "sponsor-uid", "email": "[email protected]"}
770-
]
769+
return_value=[{"user_id": "sponsor-uid", "email": "[email protected]"}]
771770
),
772771
),
773772
]
@@ -1440,6 +1439,26 @@ async def test_not_licensed(self) -> None:
14401439
agent_user_id="agent-oid-123",
14411440
)
14421441

1442+
@respx.mock
1443+
@pytest.mark.asyncio
1444+
async def test_429_http_date_retry_after_raises_rate_limit(self) -> None:
1445+
from entrabot.tools.teams import create_one_on_one_chat
1446+
1447+
respx.post(f"{GRAPH_BASE}/chats").mock(
1448+
return_value=httpx.Response(
1449+
429,
1450+
headers={"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"},
1451+
)
1452+
)
1453+
with pytest.raises(RateLimitError) as exc_info:
1454+
await create_one_on_one_chat(
1455+
token="tok",
1456+
target_email="[email protected]",
1457+
agent_user_id="agent-oid-123",
1458+
)
1459+
1460+
assert exc_info.value.retry_after == 0
1461+
14431462

14441463
# ---------------------------------------------------------------------------
14451464
# fetch_hosted_image

0 commit comments

Comments
 (0)