Files
shockbot/utils/retry.ts
T
pullfrog[bot] 851e49e2d7 action: retry transient GitHub 422 "internal error" on review submission (#610)
* action: retry transient GitHub 422 "internal error" on review submission

GitHub sometimes 422s POST /pulls/{n}/reviews with body
"An internal error occurred, please try again." — a server-side hiccup
that the existing 422 handler framed with the generic
"likely causes (1)(2)(3)" prompt listing affected comments. the agent
dutifully refetched the diff, dropped comments, and resubmitted, hitting
the same transient error on a shifting affected-comments list until
GitHub accepted. some runs logged 8+ spurious retries with ~11 minutes
of wall-clock, dropping valid inline comments along the way.

detect the transient body explicitly, retry in-tool twice with 1s/3s
backoff, and surface a distinct error on exhaustion that tells the agent
this is a GitHub-side issue — do not modify inline comments, wait and
retry or fall back to a body-only review. closes #584.

* action: use retry util for transient review 422, drop isTransientReviewError tests

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-05-08 20:33:01 +00:00

59 lines
1.7 KiB
TypeScript

import { setTimeout as sleep } from "node:timers/promises";
import { log } from "./cli.ts";
export type RetryOptions = {
maxAttempts?: number;
delayMs?: number;
/**
* explicit delay schedule — one entry per retry (length N ⇒ N+1 attempts).
* when set, overrides `maxAttempts` and `delayMs`. e.g. `[1_000, 3_000]`
* means up to 3 attempts, sleeping 1s before retry 2 and 3s before retry 3.
*/
delaysMs?: readonly number[];
shouldRetry?: (error: unknown) => boolean;
label?: string;
};
const defaultShouldRetry = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
// retry on transient network errors
return (
error.name === "AbortError" ||
error.message.includes("fetch failed") ||
error.message.includes("ECONNRESET") ||
error.message.includes("ETIMEDOUT")
);
};
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
const label = options.label ?? "operation";
const delays = options.delaysMs
? Array.from(options.delaysMs)
: Array.from(
{ length: (options.maxAttempts ?? 3) - 1 },
(_, i) => (options.delayMs ?? 1000) * (i + 1)
);
const maxAttempts = delays.length + 1;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
const delay = delays[attempt - 1]!;
log.info(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
await sleep(delay);
}
}
throw lastError;
}