@@ -948,6 +948,7 @@ jobs:
948948 const failedIssues = [];
949949 let aiGameAttempts = 0;
950950 let aiGameMatches = 0;
951+ let aiGameRateLimited = 0;
951952
952953 // === Helpers (mirrored from issue-ai-maintenance) ===
953954
@@ -1027,6 +1028,39 @@ jobs:
10271028 ].some((term) => normalized.includes(term));
10281029 }
10291030
1031+ function parseAiRateLimitInfo(response) {
1032+ const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After') || '';
1033+ const limit = response.headers.get('x-ratelimit-limit') || '';
1034+ const remaining = response.headers.get('x-ratelimit-remaining') || '';
1035+ const resetEpoch = response.headers.get('x-ratelimit-reset') || '';
1036+ const requestId = response.headers.get('x-github-request-id') || '';
1037+
1038+ let resetIso = '';
1039+ const parsedReset = Number.parseInt(resetEpoch, 10);
1040+ if (Number.isFinite(parsedReset) && parsedReset > 0) {
1041+ resetIso = new Date(parsedReset * 1000).toISOString();
1042+ }
1043+
1044+ return {
1045+ retryAfter,
1046+ limit,
1047+ remaining,
1048+ resetEpoch,
1049+ resetIso,
1050+ requestId,
1051+ };
1052+ }
1053+
1054+ function formatAiRateLimitInfo(info) {
1055+ const parts = [];
1056+ if (info.retryAfter) parts.push(`retry-after=${info.retryAfter}s`);
1057+ if (info.limit) parts.push(`limit=${info.limit}`);
1058+ if (info.remaining) parts.push(`remaining=${info.remaining}`);
1059+ if (info.resetEpoch) parts.push(`reset=${info.resetEpoch}${info.resetIso ? ` (${info.resetIso})` : ''}`);
1060+ if (info.requestId) parts.push(`request-id=${info.requestId}`);
1061+ return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned';
1062+ }
1063+
10301064 function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) {
10311065 const normalizedText = normalizeName(text);
10321066 if (!normalizedText || !targetLabel) return false;
@@ -1406,8 +1440,13 @@ jobs:
14061440
14071441 // On 429 honour Retry-After (capped at 60 s) then retry once.
14081442 if (res.status === 429) {
1409- const retryAfter = Math.min(Number.parseInt(res.headers.get('Retry-After') || '10', 10), 60);
1410- console.log(`#${rawIssue.number}: AI fallback rate-limited — waiting ${retryAfter}s then retrying…`);
1443+ aiGameRateLimited += 1;
1444+ const rateInfo = parseAiRateLimitInfo(res);
1445+ const rawRetryAfter = Number.parseInt(rateInfo.retryAfter || '10', 10);
1446+ const retryAfter = Math.min(Number.isFinite(rawRetryAfter) ? rawRetryAfter : 10, 60);
1447+ console.log(
1448+ `#${rawIssue.number}: AI fallback rate-limited - waiting ${retryAfter}s then retrying (${formatAiRateLimitInfo(rateInfo)})`
1449+ );
14111450 await new Promise((r) => setTimeout(r, retryAfter * 1000));
14121451 res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) });
14131452 }
@@ -1454,7 +1493,14 @@ jobs:
14541493 }
14551494 }
14561495 } else {
1457- console.log(`#${rawIssue.number}: AI fallback skipped (HTTP ${res.status})`);
1496+ if (res.status === 429) {
1497+ const rateInfo = parseAiRateLimitInfo(res);
1498+ console.log(
1499+ `#${rawIssue.number}: AI fallback skipped (HTTP 429, ${formatAiRateLimitInfo(rateInfo)})`
1500+ );
1501+ } else {
1502+ console.log(`#${rawIssue.number}: AI fallback skipped (HTTP ${res.status})`);
1503+ }
14581504 }
14591505 } catch (err) {
14601506 console.log(`#${rawIssue.number}: AI fallback error: ${err.message}`);
@@ -1565,6 +1611,7 @@ jobs:
15651611 { data: 'AI fallback', header: true },
15661612 { data: 'AI attempts', header: true },
15671613 { data: 'AI matches', header: true },
1614+ { data: 'AI 429s', header: true },
15681615 { data: 'Target issues', header: true },
15691616 { data: 'Processed', header: true },
15701617 { data: 'Failures', header: true },
@@ -1575,6 +1622,7 @@ jobs:
15751622 useAiGameFallback ? 'enabled' : 'disabled',
15761623 String(aiGameAttempts),
15771624 String(aiGameMatches),
1625+ String(aiGameRateLimited),
15781626 String(selectedTargets.length),
15791627 String(processed),
15801628 String(failedIssues.length),
0 commit comments