@@ -1325,7 +1325,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13251325 assert "abandoned 2 of 3" in caplog .text
13261326
13271327
1328- def test_rate_limit_still_dispatches_earlier_completed_candidate (make_config , monkeypatch , caplog ):
1328+ def test_rate_limit_defers_completed_candidate_without_dispatching (make_config , monkeypatch , caplog ):
13291329 from github import RateLimitExceededException
13301330
13311331 monkeypatch .setattr (review , "_FINGERPRINT_WORKERS" , 1 )
@@ -1353,17 +1353,64 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13531353 now = lambda : _NOW ,
13541354 )
13551355
1356- # PR1 completes as a candidate before PR2 hits the rate limit, so the partial success is preserved
1357- # -- PR1 still dispatches. PR2 trips the cancel event, so PR3 short-circuits without being
1358- # fingerprinted: PR2 lands in `failed`, PR3 is counted as abandoned (not failed).
1356+ # PR1 completes as a candidate before PR2 hits the rate limit, but a dispatch is a workflow_dispatch
1357+ # POST on the same throttled token -- exactly what secondary-rate-limit detection punishes -- so the
1358+ # whole dispatch phase is skipped once the cancel event trips. PR1 is deferred, not lost: no state
1359+ # row is written, so the next scan re-fingerprints and dispatches it once the limit clears. PR2 trips
1360+ # the event, so PR3 short-circuits without being fingerprinted.
13591361 assert fingerprinted == [1 , 2 ]
1360- assert dispatched == [1 ]
1361- # The failure and the abandonment are surfaced distinctly in the fail-closed signal, and the
1362- # abandonment is logged as its own warning rather than folded into the failure count.
1362+ assert dispatched == []
1363+ # The deferral is surfaced by the merged rate-limit warning, and the scan still fails closed: PR2
1364+ # (failed) and PR3 (abandoned) are carried distinctly in the end-of-scan RuntimeError.
1365+ assert "skipping dispatch of 1 completed candidate(s) this pass" in caplog .text
1366+ assert "abandoned 1 of 3" in caplog .text
13631367 message = str (excinfo .value )
13641368 assert "1 PR(s) failed during scan: [2]" in message
13651369 assert "1 PR(s) abandoned due to rate limit: [3]" in message
1366- assert "abandoned 1 of 3" in caplog .text
1370+
1371+
1372+ def test_rate_limit_on_last_task_skips_dispatch_with_no_abandoned (make_config , monkeypatch , caplog ):
1373+ from github import RateLimitExceededException
1374+
1375+ # The gate keys off cancel_event, not the `abandoned` list, precisely for this case: the rate limit
1376+ # hits the LAST task to run, so no queued task is left to short-circuit to _CANCELLED and `abandoned`
1377+ # stays empty -- yet the event IS set, so dispatch must still be skipped. A regression to `if abandoned:`
1378+ # would pass every other test but wrongly dispatch the completed candidates onto the throttled token here.
1379+ monkeypatch .setattr (review , "_FINGERPRINT_WORKERS" , 1 )
1380+ fingerprinted : list [int ] = []
1381+ dispatched : list [int ] = []
1382+
1383+ def fingerprint (_client , number , _authorized , _skip ):
1384+ fingerprinted .append (number )
1385+ if number == 3 :
1386+ raise RateLimitExceededException (403 )
1387+ return (f"headsha{ number } " , _HASH_A )
1388+
1389+ def fake_dispatch (_client , number , _head_sha , _eval_hash , _ref ):
1390+ dispatched .append (number )
1391+
1392+ with caplog .at_level (logging .WARNING , logger = "greenlight" ), pytest .raises (RuntimeError ) as excinfo :
1393+ review .run (
1394+ make_config (github_token = "t" ),
1395+ build_github = lambda _token , ** _kwargs : _CLIENT ,
1396+ fetch = lambda _client : [_open_pr (1 ), _open_pr (2 ), _open_pr (3 )],
1397+ fingerprint = fingerprint ,
1398+ read_state = lambda _repo , _numbers : {},
1399+ dispatch = fake_dispatch ,
1400+ resolve_authorized = lambda : _AUTHORIZED ,
1401+ now = lambda : _NOW ,
1402+ )
1403+
1404+ # PR1 and PR2 complete as candidates; PR3 (the last task) trips the cancel event with nothing left to
1405+ # cancel, so abandoned is empty. Dispatch is still skipped -- the two completed candidates are deferred.
1406+ assert fingerprinted == [1 , 2 , 3 ]
1407+ assert dispatched == []
1408+ assert "skipping dispatch of 2 completed candidate(s) this pass" in caplog .text
1409+ # abandoned is empty, so the fail-closed RuntimeError carries only the failed clause (the last PR) and
1410+ # no "abandoned" clause -- the scan still signals incomplete via the rate-limited task landing in failed.
1411+ message = str (excinfo .value )
1412+ assert "1 PR(s) failed during scan: [3]" in message
1413+ assert "abandoned" not in message
13671414
13681415
13691416def test_rate_limit_abandonment_breaks_max_dispatch_batches (make_config , monkeypatch ):
@@ -1396,10 +1443,43 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13961443 )
13971444
13981445 # The capped path (_fingerprint_until_dispatchable) fingerprints in worker-sized batches (size 1
1399- # here). PR1 dispatches, PR2 hits the rate limit and trips the cancel event, so the batch loop
1400- # breaks before submitting PR3 -- the cap of 5 is never the limiter, the cancellation is.
1446+ # here). PR1 completes, PR2 hits the rate limit and trips the cancel event, so the batch loop
1447+ # breaks before submitting PR3 -- the cap of 5 is never the limiter, the cancellation is. The
1448+ # tripped event then gates the dispatch phase, so PR1 is deferred rather than dispatched.
14011449 assert fingerprinted == [1 , 2 ]
1402- assert dispatched == [1 ]
1450+ assert dispatched == []
1451+
1452+
1453+ def test_normal_scan_dispatches_when_not_rate_limited (make_config , monkeypatch , caplog ):
1454+ # Guard against over-gating: with no rate limit the cancel event never trips, so the dispatch phase
1455+ # must run exactly as before -- every completed candidate is dispatched and no deferral warning fires.
1456+ monkeypatch .setattr (review , "_FINGERPRINT_WORKERS" , 1 )
1457+ fingerprinted : list [int ] = []
1458+ dispatched : list [int ] = []
1459+
1460+ def fingerprint (_client , number , _authorized , _skip ):
1461+ fingerprinted .append (number )
1462+ return (f"headsha{ number } " , _HASH_A )
1463+
1464+ def fake_dispatch (_client , number , _head_sha , _eval_hash , _ref ):
1465+ dispatched .append (number )
1466+
1467+ with caplog .at_level (logging .WARNING , logger = "greenlight" ):
1468+ review .run (
1469+ make_config (github_token = "t" ),
1470+ build_github = lambda _token , ** _kwargs : _CLIENT ,
1471+ fetch = lambda _client : [_open_pr (1 ), _open_pr (2 ), _open_pr (3 )],
1472+ fingerprint = fingerprint ,
1473+ read_state = lambda _repo , _numbers : {},
1474+ dispatch = fake_dispatch ,
1475+ resolve_authorized = lambda : _AUTHORIZED ,
1476+ now = lambda : _NOW ,
1477+ )
1478+
1479+ assert fingerprinted == [1 , 2 , 3 ]
1480+ assert dispatched == [1 , 2 , 3 ]
1481+ assert "skipping dispatch" not in caplog .text
1482+ assert "rate limit" not in caplog .text
14031483
14041484
14051485def test_fetch_failure_still_closes_main_client (make_config ):
0 commit comments