Skip to content

waitForRememberJob (single) throws on timeout, while waitForRememberJobs (bulk) gracefully returns for the identical failure condition #691

Description

@Collinxweb

Impact

The single-item and bulk-item APIs handle the exact same failure mode — a job not reaching a terminal state before the deadline — with two incompatible contracts. A developer who validates their error-handling against the bulk API (which returns a normal result object) and assumes the single-item API behaves the same way will ship code with an unhandled exception in production. This is especially likely because both methods are part of the same class, documented side-by-side, and conceptually identical.

Expected Behavior

Both methods should handle a polling timeout the same way — either both throw, or both resolve with a "timeout" status result. Given that RememberBulkItemResult already models status: "done" | "failed" | "timeout" as a legitimate, non-exceptional outcome, the single-item path should follow the same contract.

Actual Behavior

Single-item (memwal.js, waitForRememberJob) — throws: ```
throw Object.assign(new Error(remember job timed out after ${timeoutMs}ms (job_id=${jobId})), { status: 504, jobId });

Bulk (memwal.js, waitForRememberJobs) — returns gracefully:```
const results = jobIds.map((jobId, idx) => ({
    id: jobId,
    blob_id: "",
    status: "timeout",
    namespace: namespaces[idx] ?? this.namespace,
    error: `polling timed out after ${timeoutMs}ms`,
}));

Additionally, waitForRememberJob's default timeoutMs is 60,000ms (60 seconds) — a value used implicitly by the class-level JSDoc's documented usage example (await memwal.waitForRememberJob(accepted.job_id), no options passed) — meaning any developer following the documented example exactly is exposed to this throw-on-timeout behavior with no indication from the docs that it can happen, or that raising timeoutMs is advisable.

Reproduction

// Bulk: resolves gracefully with status "timeout" in the result array
const bulkResult = await memwal.waitForRememberJobs([slowJobId], undefined, { timeoutMs: 100 });
console.log(bulkResult.results[0].status); // "timeout" — no exception

// Single: throws instead
try {
  await memwal.waitForRememberJob(slowJobId, { timeoutMs: 100 });
} catch (e) {
  console.log(e.message); // "remember job timed out after 100ms (job_id=...)"
}

Real-world evidence

Encountered directly during development: waitForRememberJob threw after both a 60s and a 180s explicit timeoutMs on a checkpoint write, while the underlying write later succeeded (confirmed via a separate recall() call — the write existed server-side despite the client throwing).

Live reproduction (fresh run, forced with timeoutMs: 1 so both paths are guaranteed to hit the timeout condition deterministically)

--- SINGLE: waitForRememberJob with timeoutMs=1 ---
single: THREW -- remember job timed out after 1ms (job_id=8e669930-ae7c-431a-920b-fdbc61f4208d)

--- BULK: waitForRememberJobs with timeoutMs=1, same condition ---
bulk: resolved gracefully, result:
{
  "id": "d7807054-209d-417f-a00c-aa21e6f75e4f",
  "blob_id": "",
  "status": "timeout",
  "namespace": "bug-repro",
  "error": "polling timed out after 1ms"
}

[issue3-timeout-output.png

— live terminal run: Showing the single call throw and the bulk call's graceful result side by side]

Suggested Fix

Change waitForRememberJob to resolve with a { status: "timeout", ... } result object matching the shape already used by waitForRememberJobs, rather than throwing. If throwing is intentionally preferred for the single-item case, document the discrepancy explicitly in both methods' JSDoc, and note the default timeoutMs value directly in the class-level @example.

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