@@ -1336,7 +1336,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13361336 assert "abandoned 2 of 3" in caplog .text
13371337
13381338
1339- def test_rate_limit_still_dispatches_earlier_completed_candidate (make_config , monkeypatch , caplog ):
1339+ def test_rate_limit_defers_completed_candidate_without_dispatching (make_config , monkeypatch , caplog ):
13401340 from github import RateLimitExceededException
13411341
13421342 monkeypatch .setattr (review , "_FINGERPRINT_WORKERS" , 1 )
@@ -1364,17 +1364,64 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13641364 now = lambda : _NOW ,
13651365 )
13661366
1367- # PR1 completes as a candidate before PR2 hits the rate limit, so the partial success is preserved
1368- # -- PR1 still dispatches. PR2 trips the cancel event, so PR3 short-circuits without being
1369- # fingerprinted: PR2 lands in `failed`, PR3 is counted as abandoned (not failed).
1367+ # PR1 completes as a candidate before PR2 hits the rate limit, but a dispatch is a workflow_dispatch
1368+ # POST on the same throttled token -- exactly what secondary-rate-limit detection punishes -- so the
1369+ # whole dispatch phase is skipped once the cancel event trips. PR1 is deferred, not lost: no state
1370+ # row is written, so the next scan re-fingerprints and dispatches it once the limit clears. PR2 trips
1371+ # the event, so PR3 short-circuits without being fingerprinted.
13701372 assert fingerprinted == [1 , 2 ]
1371- assert dispatched == [1 ]
1372- # The failure and the abandonment are surfaced distinctly in the fail-closed signal, and the
1373- # abandonment is logged as its own warning rather than folded into the failure count.
1373+ assert dispatched == []
1374+ # The deferral is surfaced by the merged rate-limit warning, and the scan still fails closed: PR2
1375+ # (failed) and PR3 (abandoned) are carried distinctly in the end-of-scan RuntimeError.
1376+ assert "skipping dispatch of 1 completed candidate(s) this pass" in caplog .text
1377+ assert "abandoned 1 of 3" in caplog .text
13741378 message = str (excinfo .value )
13751379 assert "1 PR(s) failed during scan: [2]" in message
13761380 assert "1 PR(s) abandoned due to rate limit: [3]" in message
1377- assert "abandoned 1 of 3" in caplog .text
1381+
1382+
1383+ def test_rate_limit_on_last_task_skips_dispatch_with_no_abandoned (make_config , monkeypatch , caplog ):
1384+ from github import RateLimitExceededException
1385+
1386+ # The gate keys off cancel_event, not the `abandoned` list, precisely for this case: the rate limit
1387+ # hits the LAST task to run, so no queued task is left to short-circuit to _CANCELLED and `abandoned`
1388+ # stays empty -- yet the event IS set, so dispatch must still be skipped. A regression to `if abandoned:`
1389+ # would pass every other test but wrongly dispatch the completed candidates onto the throttled token here.
1390+ monkeypatch .setattr (review , "_FINGERPRINT_WORKERS" , 1 )
1391+ fingerprinted : list [int ] = []
1392+ dispatched : list [int ] = []
1393+
1394+ def fingerprint (_client , number , _authorized , _skip ):
1395+ fingerprinted .append (number )
1396+ if number == 3 :
1397+ raise RateLimitExceededException (403 )
1398+ return (f"headsha{ number } " , _HASH_A )
1399+
1400+ def fake_dispatch (_client , number , _head_sha , _eval_hash , _ref ):
1401+ dispatched .append (number )
1402+
1403+ with caplog .at_level (logging .WARNING , logger = "greenlight" ), pytest .raises (RuntimeError ) as excinfo :
1404+ review .run (
1405+ make_config (github_token = "t" ),
1406+ build_github = lambda _token , ** _kwargs : _CLIENT ,
1407+ fetch = lambda _client : [_open_pr (1 ), _open_pr (2 ), _open_pr (3 )],
1408+ fingerprint = fingerprint ,
1409+ read_state = lambda _repo , _numbers : {},
1410+ dispatch = fake_dispatch ,
1411+ resolve_authorized = lambda : _AUTHORIZED ,
1412+ now = lambda : _NOW ,
1413+ )
1414+
1415+ # PR1 and PR2 complete as candidates; PR3 (the last task) trips the cancel event with nothing left to
1416+ # cancel, so abandoned is empty. Dispatch is still skipped -- the two completed candidates are deferred.
1417+ assert fingerprinted == [1 , 2 , 3 ]
1418+ assert dispatched == []
1419+ assert "skipping dispatch of 2 completed candidate(s) this pass" in caplog .text
1420+ # abandoned is empty, so the fail-closed RuntimeError carries only the failed clause (the last PR) and
1421+ # no "abandoned" clause -- the scan still signals incomplete via the rate-limited task landing in failed.
1422+ message = str (excinfo .value )
1423+ assert "1 PR(s) failed during scan: [3]" in message
1424+ assert "abandoned" not in message
13781425
13791426
13801427def test_rate_limit_abandonment_breaks_max_dispatch_batches (make_config , monkeypatch ):
@@ -1407,10 +1454,43 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
14071454 )
14081455
14091456 # The capped path (_fingerprint_until_dispatchable) fingerprints in worker-sized batches (size 1
1410- # here). PR1 dispatches, PR2 hits the rate limit and trips the cancel event, so the batch loop
1411- # breaks before submitting PR3 -- the cap of 5 is never the limiter, the cancellation is.
1457+ # here). PR1 completes, PR2 hits the rate limit and trips the cancel event, so the batch loop
1458+ # breaks before submitting PR3 -- the cap of 5 is never the limiter, the cancellation is. The
1459+ # tripped event then gates the dispatch phase, so PR1 is deferred rather than dispatched.
14121460 assert fingerprinted == [1 , 2 ]
1413- assert dispatched == [1 ]
1461+ assert dispatched == []
1462+
1463+
1464+ def test_normal_scan_dispatches_when_not_rate_limited (make_config , monkeypatch , caplog ):
1465+ # Guard against over-gating: with no rate limit the cancel event never trips, so the dispatch phase
1466+ # must run exactly as before -- every completed candidate is dispatched and no deferral warning fires.
1467+ monkeypatch .setattr (review , "_FINGERPRINT_WORKERS" , 1 )
1468+ fingerprinted : list [int ] = []
1469+ dispatched : list [int ] = []
1470+
1471+ def fingerprint (_client , number , _authorized , _skip ):
1472+ fingerprinted .append (number )
1473+ return (f"headsha{ number } " , _HASH_A )
1474+
1475+ def fake_dispatch (_client , number , _head_sha , _eval_hash , _ref ):
1476+ dispatched .append (number )
1477+
1478+ with caplog .at_level (logging .WARNING , logger = "greenlight" ):
1479+ review .run (
1480+ make_config (github_token = "t" ),
1481+ build_github = lambda _token , ** _kwargs : _CLIENT ,
1482+ fetch = lambda _client : [_open_pr (1 ), _open_pr (2 ), _open_pr (3 )],
1483+ fingerprint = fingerprint ,
1484+ read_state = lambda _repo , _numbers : {},
1485+ dispatch = fake_dispatch ,
1486+ resolve_authorized = lambda : _AUTHORIZED ,
1487+ now = lambda : _NOW ,
1488+ )
1489+
1490+ assert fingerprinted == [1 , 2 , 3 ]
1491+ assert dispatched == [1 , 2 , 3 ]
1492+ assert "skipping dispatch" not in caplog .text
1493+ assert "rate limit" not in caplog .text
14141494
14151495
14161496def test_fetch_failure_still_closes_main_client (make_config ):
0 commit comments