@@ -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+
117181async 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
0 commit comments