agents
Version:
A home for your AI agents
249 lines (248 loc) • 12.8 kB
JavaScript
//#region src/retries.ts
/**
* Validate retry options eagerly so invalid config fails at enqueue/schedule time
* rather than at execution time. Checks individual field ranges, enforces integer
* maxAttempts, and validates cross-field constraints after resolving against
* defaults when provided.
*/
/** Fill unset per-call retry options from a required set of defaults. */
function resolveRetryConfig(retry, defaults) {
return {
maxAttempts: retry?.maxAttempts ?? defaults.maxAttempts,
baseDelayMs: retry?.baseDelayMs ?? defaults.baseDelayMs,
maxDelayMs: retry?.maxDelayMs ?? defaults.maxDelayMs
};
}
function validateRetryOptions(options, defaults) {
if (options.maxAttempts !== void 0) {
if (!Number.isFinite(options.maxAttempts) || options.maxAttempts < 1) throw new Error("retry.maxAttempts must be >= 1");
if (!Number.isInteger(options.maxAttempts)) throw new Error("retry.maxAttempts must be an integer");
}
if (options.baseDelayMs !== void 0) {
if (!Number.isFinite(options.baseDelayMs) || options.baseDelayMs <= 0) throw new Error("retry.baseDelayMs must be > 0");
}
if (options.maxDelayMs !== void 0) {
if (!Number.isFinite(options.maxDelayMs) || options.maxDelayMs <= 0) throw new Error("retry.maxDelayMs must be > 0");
}
const resolvedBase = options.baseDelayMs ?? defaults?.baseDelayMs;
const resolvedMax = options.maxDelayMs ?? defaults?.maxDelayMs;
if (resolvedBase !== void 0 && resolvedMax !== void 0 && resolvedBase > resolvedMax) throw new Error("retry.baseDelayMs must be <= retry.maxDelayMs");
}
/**
* Returns the number of milliseconds to wait before retrying a request.
* Uses the "Full Jitter" approach from
* https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
*
* @param attempt The current attempt number (1-indexed).
* @param baseDelayMs Base delay multiplier in ms.
* @param maxDelayMs Maximum delay cap in ms.
* @returns Milliseconds to wait before retrying.
*/
function jitterBackoff(attempt, baseDelayMs, maxDelayMs) {
const upperBoundMs = Math.min(2 ** attempt * baseDelayMs, maxDelayMs);
return Math.floor(Math.random() * upperBoundMs);
}
/**
* Retry an async function up to `n` total attempts with jittered exponential backoff.
*
* @param n Total number of attempts (must be a finite integer >= 1).
* @param fn The async function to retry. Receives the current attempt number (1-indexed).
* @param options Retry configuration.
* @returns The result of `fn` on success.
* @throws The last error if all attempts fail or `shouldRetry` returns false.
*/
async function tryN(n, fn, options) {
if (!Number.isFinite(n) || n < 1) throw new Error("retry.maxAttempts must be >= 1");
n = Math.floor(n);
const rawBase = options?.baseDelayMs ?? 100;
const rawMax = options?.maxDelayMs ?? 3e3;
if (!Number.isFinite(rawBase) || rawBase <= 0) throw new Error("retry.baseDelayMs must be > 0");
if (!Number.isFinite(rawMax) || rawMax <= 0) throw new Error("retry.maxDelayMs must be > 0");
const baseDelayMs = Math.floor(rawBase);
const maxDelayMs = Math.floor(rawMax);
if (baseDelayMs > maxDelayMs) throw new Error("retry.baseDelayMs must be <= retry.maxDelayMs");
let attempt = 1;
while (true) try {
return await fn(attempt);
} catch (err) {
const nextAttempt = attempt + 1;
if (nextAttempt > n || options?.shouldRetry && !options.shouldRetry(err, nextAttempt)) throw err;
const delay = jitterBackoff(attempt, baseDelayMs, maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
attempt = nextAttempt;
}
}
/**
* Returns true if the given error is retryable according to Durable Object error handling.
* See https://developers.cloudflare.com/durable-objects/best-practices/error-handling/
*
* An error is retryable if it has `retryable: true` but is NOT an overloaded error.
*/
function isErrorRetryable(err) {
if (typeof err !== "object" || err === null) return false;
const msg = String(err);
const typed = err;
return Boolean(typed.retryable) && !typed.overloaded && !msg.includes("Durable Object is overloaded");
}
/**
* The "superseded isolate" platform messages — the invocation is running on an
* isolate the platform has replaced with a new version (a deploy / code
* update). For the rest of that invocation every operation throws the same
* error (code never reloads mid-invocation), so in-process retries are futile;
* but the next fresh invocation runs the new code and succeeds.
*
* workerd surfaces this as a plain `Error` with one of a few messages, all the
* same failure class — a message match is the only signal:
* - "Durable Object reset because its code was updated." (DO storage op on a
* superseded isolate / deploy bounce)
* - "This script has been upgraded. Please send a new request to connect to
* the new version." (a stub/connection to a superseded script; the message
* literally instructs the caller to retry on the new version)
*
* The match stays close to the verbatim platform strings (rather than a loose
* "upgraded"/"reset" substring) so an ordinary application error that happens
* to mention those words is NOT misclassified as a supersede.
*/
const SUPERSEDED_ISOLATE_PATTERN = /reset because its code was updated|this script has been upgraded/i;
/**
* The "Network connection lost." platform transient — the connection between
* the isolate and its storage (or another DO) dropped. Unlike a supersede this
* MAY succeed on an in-process retry (a momentary blip), so it must not skip
* the in-process retry budget — but during a deploy-reset window it never
* succeeds in-process and surfaces interleaved with the supersede messages
* (SQL ops throw `SqlError: SQL query failed: Network connection lost.` while
* KV ops throw the reset message), so on retry exhaustion it must be treated
* as the platform's failure, not the callback's.
*/
const CONNECTION_LOST_PATTERN = /network connection lost/i;
/**
* The exact Durable Object storage-reset platform signal. Keep this narrow:
* ordinary SQL and generic internal errors are application failures. This is a
* transient storage reset, not a memory-limit poison pill.
*/
const STORAGE_RESET_PATTERN = /Internal error in Durable Object storage caused object to be reset/i;
/**
* The Durable Object memory-limit reset — the isolate exceeded its 128 MB limit
* and was reset by the platform (workerd surfaces this verbatim as
* "Durable Object's isolate exceeded its memory limit and was reset."; the D1
* sibling is "D1 DB's isolate exceeded its memory limit and was reset.").
*
* The match is the broad shared fragment "exceeded its memory limit" rather than
* the full "...and was reset" sentence: real-world surfacings truncate or reword
* the tail (some log pipelines clip the message; D1/storage wrappers re-prefix
* it), and a customer-reported loop (#1825) showed lines carrying only the
* "exceeded its memory limit" fragment. Missing a surfacing here means the
* circuit breaker never engages, so we err toward the broader match — and even a
* false positive is fail-safe (a tightly-bounded retry-then-seal, not data loss).
*
* This is DELIBERATELY a separate class from `SUPERSEDED_ISOLATE_PATTERN` /
* {@link isPlatformTransientError}, and is NOT folded into them. A supersede or
* connection-lost transient means "re-run the same work and it succeeds on a
* healthy isolate" — those classes can be deferred and retried *indefinitely*. A
* memory-limit reset is the opposite: re-running the SAME memory-heavy work
* deterministically re-OOMs (the footprint, not the platform, is the cause), so
* deferring it indefinitely would PRESERVE the one-shot row and re-run the
* doomed work forever (amplifying the loop and cost — see #1825). It is a
* poison-pill signal: callers must bound retries tightly and then SEAL.
*
* Accordingly the schedule executor (`_executeScheduleCallback`) and the
* alarm-boundary circuit breaker (`Agent.alarm`) treat it as its OWN class: a
* memory-limit reset is re-thrown (row preserved) so it reaches the breaker,
* which tolerates a few strikes (`maxAlarmMemoryLimitStrikes`) and then seals +
* purges the looping row — i.e. *bounded* deferral, never the unbounded deferral
* the transient classes get.
*/
const MEMORY_LIMIT_RESET_PATTERN = /exceeded its memory limit/i;
function errorMessageOf(error) {
return error instanceof Error ? error.message : typeof error === "string" ? error : "";
}
/**
* Iterate an error and its `cause` chain (depth-limited so a cyclic chain
* can't spin). Wrappers like `SqlError` carry the original platform error in
* `cause` and may not propagate signal properties (e.g. the CF `retryable`
* flag), so classification must look through them.
*/
function* selfAndCauses(error) {
let current = error;
for (let depth = 0; depth < 8 && current != null; depth++) {
yield current;
current = typeof current === "object" ? current.cause : void 0;
}
}
/**
* Whether an error (or anything in its `cause` chain) is a transient
* "superseded isolate" failure — see `SUPERSEDED_ISOLATE_PATTERN`. In-process
* retries are futile for this class; the work must be deferred to a fresh
* invocation, which runs the new code and succeeds.
*/
function isDurableObjectCodeUpdateReset(error) {
for (const e of selfAndCauses(error)) if (SUPERSEDED_ISOLATE_PATTERN.test(errorMessageOf(e))) return true;
return false;
}
/**
* Whether an error (or anything in its `cause` chain) carries the exact
* Durable Object storage-reset platform fragment. Generic SQL/internal errors
* deliberately do not qualify.
*/
function isDurableObjectStorageReset(error) {
for (const e of selfAndCauses(error)) if (STORAGE_RESET_PATTERN.test(errorMessageOf(e))) return true;
return false;
}
/**
* Whether an error (or anything in its `cause` chain, or a raw error-message
* string) is a Durable Object memory-limit reset — see
* {@link MEMORY_LIMIT_RESET_PATTERN}. Unlike {@link isPlatformTransientError},
* re-running the same work re-OOMs deterministically, so callers must NOT defer
* it like a transient; they should bound retries tightly and then seal (#1825).
*/
function isDurableObjectMemoryLimitReset(error) {
for (const e of selfAndCauses(error)) if (MEMORY_LIMIT_RESET_PATTERN.test(errorMessageOf(e))) return true;
return false;
}
/**
* Whether an error (or anything in its `cause` chain) is a transient failure
* of the PLATFORM rather than of the code that threw it:
*
* - a superseded-isolate reset ("reset because its code was updated" /
* "this script has been upgraded") — a deploy replaced the isolate;
* - an error the platform itself flags `retryable: true` (excluding
* overloaded errors, where retrying the same object won't help) — see
* `isErrorRetryable`;
* - "Network connection lost." — the storage/stub connection dropped. The
* CF `retryable` flag does not survive error wrappers (e.g. `SqlError`
* copies only the message + `cause`) and is absent in some local-dev
* shapes, so the verbatim message is matched as well;
* - the exact "Internal error in Durable Object storage caused object to be
* reset" platform fragment. Generic internal and SQL errors remain fatal.
*
* Used to decide whether failed work should be RE-RUN LATER (platform
* transient — the same work succeeds once the platform recovers, typically
* seconds after a deploy) versus ABANDONED as genuinely failing (application
* error — re-running yields the same failure). A genuine application error
* carries none of these signals, so it is never misclassified by this check.
*/
/**
* Whether a failure is the PLATFORM's rather than the application's — any
* platform transient (see {@link isPlatformTransientError}, which includes
* superseded-isolate resets) or a memory-limit reset. Failed work in this
* class must be PRESERVED and deferred, never completed as an application
* failure. The two sub-classes defer differently: transients re-run
* indefinitely (the platform recovers), while memory-limit deferral is
* bounded by the alarm circuit breaker (#1825).
*/
function isPlatformFailure(error) {
return isPlatformTransientError(error) || isDurableObjectMemoryLimitReset(error);
}
function isPlatformTransientError(error) {
for (const e of selfAndCauses(error)) {
const message = errorMessageOf(e);
if (SUPERSEDED_ISOLATE_PATTERN.test(message)) return true;
if (CONNECTION_LOST_PATTERN.test(message)) return true;
if (STORAGE_RESET_PATTERN.test(message)) return true;
if (isErrorRetryable(e)) return true;
}
return false;
}
//#endregion
export { isDurableObjectCodeUpdateReset, isDurableObjectMemoryLimitReset, isDurableObjectStorageReset, isErrorRetryable, isPlatformFailure, isPlatformTransientError, jitterBackoff, resolveRetryConfig, tryN, validateRetryOptions };
//# sourceMappingURL=retries.js.map