What
WorkerServerBase.postError flattened a thrown value to { message, name, stack } before posting it to the main thread, and rehydrateWorkerError rebuilt a bare Error from those three fields. Nothing else crossed.
$ sed -n '56,67p' packages/util/src/worker/scrubStack.ts # before this change
export function rehydrateWorkerError(data: unknown): Error {
…
const payload = data as { message?: string; name?: string; stack?: string };
…
return Object.assign(new Error(payload.message ?? asText(data)), {
name: payload.name ?? "Error",
…
});
}
A run function registered worker-backed reaches AiJob.execute's catch through that path — AiProviderRegistry.registerAsWorkerRunFn → WorkerManager.callWorkerRunFunction → rehydrateWorkerError — so classifyProviderError received a plain Error and every instanceof in it was false:
if (err instanceof PermanentJobError || err instanceof RetryableJobError || …) return err;
if (err instanceof ProviderUnsupportedFeatureError || err instanceof ImageGenerationContentPolicyError)
return new PermanentJobError(err.message);
if (err instanceof ImageGenerationProviderError)
return err.retryable ? new RetryableJobError(…) : new PermanentJobError(…);
The classification a provider had just made was therefore discarded, and the error fell through to the message/status heuristics below it — landing on the PermanentJobError default whenever the message carried no HTTP-shaped status and no network keyword.
That is also what the HFT_NULL_PROCESSOR: message prefix is working around: it is a retryable classification encoded into the one field that does survive the trip.
Why it matters, in both directions
- A worker-side rate limit or transient 5xx arrives as an unlabelled failure and is failed permanently — the retry the provider asked for never happens.
- The reverse is worse and was reproducible: a
ImageGenerationContentPolicyError (retryable = false) whose provider reason happens to contain "timed out" fell through to message.includes("timed out") and was classified RetryableJobError. A refusal that cannot succeed was re-run on the attempt budget.
Two adjacent facts that make it a structural gap rather than one bad branch:
JobError.retryable was write-only. RetryableJobError's constructor sets it and nothing in the tree ever read it; every consumer branched on instanceof RetryableJobError. Two representations of one fact, and the one that is documented as the control was inert.
- Providers declare retryability three different ways: a
JobError subclass (deepseek, chrome-ai, anthropic), a retryable field on a non-JobError class (the four image-generation providers, through ImageGenerationErrors.ts), and a magic message prefix (huggingface-transformers). Only the first two are even in principle recoverable at a boundary that keeps name and message.
Fix
Landed on claude/upbeat-feynman-59uare (ddf6923f2). One field, retryable, read through declaredRetryability / isRetryableError (packages/job-queue/src/job/Retryability.ts) and carried across the worker boundary by workerErrorPayload / rehydrateWorkerError. Unclassified stays permanent. Details and the reinstatement proof are in the comment below.
Not the finding as originally framed
The original report was that retryable is "consulted at exactly one call site, AiJob.ts:107, while six cloud providers classify errors". The single call site was real; the diagnosis was not. AiJob.execute is the only funnel — both DirectExecutionStrategy and QueuedExecutionStrategy call it — so an in-process error's classification was never discarded, and every downstream consumer read the resulting JobError subclass. The leak is the worker serialization boundary, and it is invisible from a grep of retryable.
Measured on origin/main @ 2d36880 (0.6.0).
What
WorkerServerBase.postErrorflattened a thrown value to{ message, name, stack }before posting it to the main thread, andrehydrateWorkerErrorrebuilt a bareErrorfrom those three fields. Nothing else crossed.A run function registered worker-backed reaches
AiJob.execute's catch through that path —AiProviderRegistry.registerAsWorkerRunFn→WorkerManager.callWorkerRunFunction→rehydrateWorkerError— soclassifyProviderErrorreceived a plainErrorand everyinstanceofin it was false:The classification a provider had just made was therefore discarded, and the error fell through to the message/status heuristics below it — landing on the
PermanentJobErrordefault whenever the message carried no HTTP-shaped status and no network keyword.That is also what the
HFT_NULL_PROCESSOR:message prefix is working around: it is a retryable classification encoded into the one field that does survive the trip.Why it matters, in both directions
ImageGenerationContentPolicyError(retryable = false) whose provider reason happens to contain "timed out" fell through tomessage.includes("timed out")and was classifiedRetryableJobError. A refusal that cannot succeed was re-run on the attempt budget.Two adjacent facts that make it a structural gap rather than one bad branch:
JobError.retryablewas write-only.RetryableJobError's constructor sets it and nothing in the tree ever read it; every consumer branched oninstanceof RetryableJobError. Two representations of one fact, and the one that is documented as the control was inert.JobErrorsubclass (deepseek, chrome-ai, anthropic), aretryablefield on a non-JobErrorclass (the four image-generation providers, throughImageGenerationErrors.ts), and a magic message prefix (huggingface-transformers). Only the first two are even in principle recoverable at a boundary that keepsnameandmessage.Fix
Landed on
claude/upbeat-feynman-59uare(ddf6923f2). One field,retryable, read throughdeclaredRetryability/isRetryableError(packages/job-queue/src/job/Retryability.ts) and carried across the worker boundary byworkerErrorPayload/rehydrateWorkerError. Unclassified stays permanent. Details and the reinstatement proof are in the comment below.Not the finding as originally framed
The original report was that
retryableis "consulted at exactly one call site,AiJob.ts:107, while six cloud providers classify errors". The single call site was real; the diagnosis was not.AiJob.executeis the only funnel — bothDirectExecutionStrategyandQueuedExecutionStrategycall it — so an in-process error's classification was never discarded, and every downstream consumer read the resultingJobErrorsubclass. The leak is the worker serialization boundary, and it is invisible from a grep ofretryable.Measured on
origin/main@2d36880(0.6.0).