Skip to content

Commit 0156561

Browse files
Flaburganvladkens
andauthored
fix: cap NetworkError retries per account, backoff then rotate (#325)
Co-authored-by: vladkens <v.pronsky@gmail.com>
1 parent 5e44fc3 commit 0156561

2 files changed

Lines changed: 188 additions & 27 deletions

File tree

tests/test_queue_client.py

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,16 @@ async def test_switch_acc_on_http_error(client_fixture: CF):
9494
assert locked1 == locked3
9595

9696

97-
async def test_retry_with_same_acc_on_network_error(client_fixture: CF):
97+
async def test_retry_with_same_acc_on_network_error(client_fixture: CF, monkeypatch):
9898
pool, client, mock = client_fixture
9999

100+
sleeps = []
101+
102+
async def fake_sleep(secs):
103+
sleeps.append(secs)
104+
105+
monkeypatch.setattr("twscrape.queue_client.asyncio.sleep", fake_sleep)
106+
100107
await client.__aenter__()
101108
locked1 = await get_locked(pool)
102109
assert len(locked1) == 1
@@ -107,13 +114,70 @@ async def test_retry_with_same_acc_on_network_error(client_fixture: CF):
107114
rep = await client.get(URL)
108115
assert rep is not None
109116
assert rep.json() == {"foo": "2"}
117+
assert sleeps == [2]
110118

111119
assert await get_locked(pool) == locked1
112120

113121
username = getattr(rep, "__username", None)
114122
assert username is not None
115123

116124

125+
async def test_network_error_rotates_account_after_3_failures(client_fixture: CF, monkeypatch):
126+
pool, client, mock = client_fixture
127+
128+
sleeps = []
129+
130+
async def fake_sleep(secs):
131+
sleeps.append(secs)
132+
133+
monkeypatch.setattr("twscrape.queue_client.asyncio.sleep", fake_sleep)
134+
135+
await client.__aenter__()
136+
assert await get_locked(pool) == {"user1"}
137+
138+
for _ in range(3):
139+
mock.add_exception(NetworkError("timeout"))
140+
mock.add_response(json={"ok": True})
141+
142+
rep = await client.get(URL)
143+
assert rep is not None
144+
assert rep.json() == {"ok": True}
145+
assert getattr(rep, "__username", None) == "user2"
146+
assert sleeps == [2, 4]
147+
148+
# user1 is short-locked (~60s transport lock, not the 15-min unknown-error lock)
149+
user1 = next(x for x in await pool.get_all() if x.username == "user1")
150+
lock_secs = (user1.locks["SearchTimeline"] - utc.now()).total_seconds()
151+
assert 0 < lock_secs <= 61
152+
153+
await client.__aexit__(None, None, None)
154+
155+
156+
async def test_network_error_counter_resets_on_account_change(client_fixture: CF, monkeypatch):
157+
pool, client, mock = client_fixture
158+
159+
async def fake_sleep(secs):
160+
pass
161+
162+
monkeypatch.setattr("twscrape.queue_client.asyncio.sleep", fake_sleep)
163+
164+
await client.__aenter__()
165+
166+
# 3 failures rotate user1 -> user2, which must get its own 3 tries: if the
167+
# counter carried over, the first failure on user2 would rotate it too and
168+
# exhaust the pool (request would return None)
169+
for _ in range(5):
170+
mock.add_exception(NetworkError("timeout"))
171+
mock.add_response(json={"ok": True})
172+
173+
rep = await client.get(URL)
174+
assert rep is not None
175+
assert rep.json() == {"ok": True}
176+
assert getattr(rep, "__username", None) == "user2"
177+
178+
await client.__aexit__(None, None, None)
179+
180+
117181
async def test_ctx_closed_on_break(client_fixture: CF):
118182
pool, client, mock = client_fixture
119183

@@ -199,31 +263,114 @@ async def fake_get(cls, username, proxy=None, cookies=None, fresh=False):
199263
# --- ConnectError ---
200264

201265

202-
async def test_connect_error_raises_after_3_retries(client_fixture: CF):
266+
async def test_connect_error_cools_account_and_rotates_after_3_retries(
267+
client_fixture: CF, monkeypatch
268+
):
203269
pool, client, mock = client_fixture
270+
271+
sleeps = []
272+
273+
async def fake_sleep(secs):
274+
sleeps.append(secs)
275+
276+
monkeypatch.setattr("twscrape.queue_client.asyncio.sleep", fake_sleep)
277+
204278
await client.__aenter__()
279+
assert await get_locked(pool) == {"user1"}
205280

206281
mock.add_exception(ConnectError("refused"))
207282
mock.add_exception(ConnectError("refused"))
208283
mock.add_exception(ConnectError("refused"))
284+
mock.add_response(json={"ok": True})
209285

210-
with pytest.raises(ConnectError):
211-
await client.get(URL)
286+
rep = await client.get(URL)
287+
assert rep is not None
288+
assert rep.json() == {"ok": True}
289+
assert getattr(rep, "__username", None) == "user2"
290+
assert sleeps == [2, 4]
291+
292+
# user1 got the short transport cooldown lock, not the 15min unknown-error lock
293+
user1 = next(x for x in await pool.get_all() if x.username == "user1")
294+
lock_secs = (user1.locks["SearchTimeline"] - utc.now()).total_seconds()
295+
assert 0 < lock_secs <= 61
296+
297+
await client.__aexit__(None, None, None)
298+
299+
300+
async def test_connect_error_recovers_before_3_retries(client_fixture: CF, monkeypatch):
301+
pool, client, mock = client_fixture
302+
303+
async def fake_sleep(secs):
304+
pass
305+
306+
monkeypatch.setattr("twscrape.queue_client.asyncio.sleep", fake_sleep)
307+
308+
await client.__aenter__()
309+
310+
mock.add_exception(ConnectError("refused"))
311+
mock.add_exception(ConnectError("refused"))
312+
mock.add_response(json={"ok": True})
313+
314+
rep = await client.get(URL)
315+
assert rep is not None
316+
assert rep.json() == {"ok": True}
317+
assert getattr(rep, "__username", None) == "user1"
212318

213319
await client.__aexit__(None, None, None)
214320

215321

216-
async def test_connect_error_recovers_before_3_retries(client_fixture: CF):
322+
async def test_alternating_categories_trip_total_safety_net(client_fixture: CF, monkeypatch):
217323
pool, client, mock = client_fixture
324+
325+
async def fake_sleep(secs):
326+
pass
327+
328+
monkeypatch.setattr("twscrape.queue_client.asyncio.sleep", fake_sleep)
329+
218330
await client.__aenter__()
331+
assert await get_locked(pool) == {"user1"}
219332

333+
# neither category alone reaches its own limit (3), but alternating between
334+
# them should still trip the combined total-failure safety net (4)
220335
mock.add_exception(ConnectError("refused"))
336+
mock.add_exception(RuntimeError("boom"))
221337
mock.add_exception(ConnectError("refused"))
338+
mock.add_exception(RuntimeError("boom"))
339+
mock.add_response(json={"ok": True})
340+
341+
rep = await client.get(URL)
342+
assert rep is not None
343+
assert getattr(rep, "__username", None) == "user2"
344+
345+
user1 = next(x for x in await pool.get_all() if x.username == "user1")
346+
assert "SearchTimeline" in user1.locks
347+
348+
await client.__aexit__(None, None, None)
349+
350+
351+
async def test_transport_error_retry_budget_is_per_account(client_fixture: CF, monkeypatch):
352+
pool, client, mock = client_fixture
353+
354+
async def fake_sleep(secs):
355+
pass
356+
357+
monkeypatch.setattr("twscrape.queue_client.asyncio.sleep", fake_sleep)
358+
359+
await client.__aenter__()
360+
361+
# 3 failures rotate user1 -> user2, which must get its own fresh budget: if the
362+
# counter carried over, the first failure on user2 would immediately rotate it
363+
# too and exhaust the whole (2-account) pool.
364+
for _ in range(5):
365+
mock.add_exception(NetworkError("timeout"))
222366
mock.add_response(json={"ok": True})
223367

224368
rep = await client.get(URL)
225369
assert rep is not None
226370
assert rep.json() == {"ok": True}
371+
assert getattr(rep, "__username", None) == "user2"
372+
373+
await client.__aexit__(None, None, None)
227374

228375
await client.__aexit__(None, None, None)
229376

twscrape/queue_client.py

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import json
33
import os
4+
from enum import Enum, auto
45
from typing import Any
56
from urllib.parse import urlparse
67

@@ -34,6 +35,11 @@ class GqlFeaturesOutdatedError(AbortReqError):
3435
"""GQL_FEATURES in api.py no longer matches the X API. Retrying cannot help."""
3536

3637

38+
class FailKind(Enum):
39+
TRANSPORT = auto()
40+
UNKNOWN = auto()
41+
42+
3743
class XClIdGenStore:
3844
items: dict[str, XClIdGen] = {}
3945

@@ -59,6 +65,13 @@ def __init__(self, acc: Account, clt: HttpClient, proxy: str | None = None):
5965
self.acc = acc
6066
self.clt = clt
6167
self.proxy = proxy
68+
self.fails = {FailKind.TRANSPORT: 0, FailKind.UNKNOWN: 0}
69+
70+
def fail(self, kind: FailKind) -> bool:
71+
"""Count a failed attempt of this kind, return whether it's still worth retrying."""
72+
fail_limit, total_fail_limit = 3, 4
73+
self.fails[kind] += 1
74+
return self.fails[kind] < fail_limit and sum(self.fails.values()) < total_fail_limit
6275

6376
async def aclose(self):
6477
await self.clt.aclose()
@@ -276,10 +289,10 @@ async def get(self, url: str, params: ReqParams = None) -> Response | None:
276289
return await self.req("GET", url, params=params)
277290

278291
async def req(self, method: HttpMethod, url: str, params: ReqParams = None) -> Response | None:
279-
unknown_retry, connection_retry = 0, 0
280-
281292
while True:
282-
ctx = await self._get_ctx() # not need to close client, class implements __aexit__
293+
# 1. same ctx until _close_ctx() clears it — that's retry vs rotate
294+
# 2. no aclose() needed here, __aexit__ handles it
295+
ctx = await self._get_ctx()
283296
if ctx is None:
284297
return None
285298

@@ -306,7 +319,6 @@ async def req(self, method: HttpMethod, url: str, params: ReqParams = None) -> R
306319
await self._check_rep(rep)
307320

308321
ctx.req_count += 1 # count only successful
309-
unknown_retry, connection_retry = 0, 0
310322
return rep
311323
except GqlFeaturesOutdatedError:
312324
# structurally invalid request, retrying cannot help — let the caller see it
@@ -328,23 +340,25 @@ async def req(self, method: HttpMethod, url: str, params: ReqParams = None) -> R
328340
)
329341
await self._close_ctx()
330342
return None
331-
except NetworkError:
332-
# http transport failed, just retry with same account
343+
except (NetworkError, ConnectError) as e:
344+
# transport failed, retry same account with backoff, then cool it down and rotate
345+
if ctx.fail(FailKind.TRANSPORT):
346+
await asyncio.sleep(2 ** ctx.fails[FailKind.TRANSPORT])
347+
continue
348+
349+
logger.warning(f"{self._format_ctx_error(ctx, e)}; cooling account for 60s")
350+
await self._close_ctx(utc.ts() + 60)
333351
continue
334-
except ConnectError as e:
335-
# if proxy misconfigured or host unreachable
336-
connection_retry += 1
337-
if connection_retry >= 3:
338-
raise e
339352
except Exception as e:
340-
unknown_retry += 1
341-
if unknown_retry >= 3:
342-
msg = [
343-
"Unknown error. Account timeouted for 15 minutes.",
344-
"Create issue please: https://github.com/vladkens/twscrape/issues",
345-
"If it mistake, you can unlock accounts with `twscrape reset_locks`. "
346-
f"Err: {self._format_ctx_error(ctx, e)}",
347-
]
348-
349-
logger.warning(" ".join(msg))
350-
await self._close_ctx(utc.ts() + 60 * 15) # 15 minutes
353+
if ctx.fail(FailKind.UNKNOWN):
354+
continue
355+
356+
msg = [
357+
"Unknown error. Account timeouted for 15 minutes.",
358+
"Create issue please: https://github.com/vladkens/twscrape/issues",
359+
"If it mistake, you can unlock accounts with `twscrape reset_locks`. "
360+
f"Err: {self._format_ctx_error(ctx, e)}",
361+
]
362+
363+
logger.warning(" ".join(msg))
364+
await self._close_ctx(utc.ts() + 60 * 15) # 15 minutes

0 commit comments

Comments
 (0)