Skip to content

A worker-thrown error loses its class at the boundary, so every worker-backed provider's retryable/permanent classification is re-guessed from the message #951

Description

@sroussey

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.registerAsWorkerRunFnWorkerManager.callWorkerRunFunctionrehydrateWorkerError — 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions