thedotmack/claude-mem · error · Error
pull
Error message
pull ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)} What it means
Thrown by the canary's `pull` method when the `/v1/sync/changes` endpoint returns a non-2xx status. The message embeds the HTTP status and the first 200 bytes of the response body to expose the server's error detail. It signals the incremental change fetch failed and the canary's replication check cannot proceed for this cycle.
Solutions
- Read the status and body in the message: 401/403 → fix device credentials; 400 → reset the `since` cursor; 5xx → check hub worker logs.
- On 404 or cursor-related errors, re-run `init` to fetch a fresh `head_seq` and reset both device cursors.
- Retry transient statuses (429, 500, 502, 503) with exponential backoff before failing the canary run.
- Verify `this.args.hub` points at the correct deployed hub and that `this.headers(deviceId)` carries a valid token.
Example fix
// before
if (!res.ok) {
throw new Error(`pull ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
}
// after
if (res.status === 429 || res.status >= 500) {
await sleep(backoffMs);
return this.pull(deviceId, since); // bounded retry
}
if (!res.ok) {
throw new Error(`pull ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
} Defensive patterns
Strategy: retry
Validate before calling
// before pulling, verify reachability and cursor sanity
if (!hubUrl.startsWith('https://')) throw new Error('invalid hub url');
if (!Number.isFinite(Number(since)) && since !== '0') console.warn('suspicious since cursor: ' + since);
const status = await fetch(`${hubUrl}/v1/sync/status`, { headers });
if (!status.ok) throw new Error(`hub unhealthy pre-pull: ${status.status}`); Type guard
function isPullSuccess(res: Response): boolean {
return res.ok && res.headers.get('content-type')?.includes('application/json') === true;
} Try / catch
try {
const { ops, headSeq } = await canary.pull(deviceId, since);
} catch (err) {
if (err instanceof Error && /^pull \d{3}:/.test(err.message)) {
const status = Number(err.message.slice(5, 8));
if (status === 429 || status >= 500) await retryWithBackoff();
else if (status === 404 || status === 400) await resetCursorViaInit();
else throw err;
} else throw err;
} Prevention
- Re-init cursors whenever the hub epoch changes or a 404/400 cursor error appears.
- Rotate device tokens in sync with hub secret updates.
- Cap `limit` and page frequency to stay under hub rate limits.
- Monitor hub worker 5xx rates so pull failures are caught upstream first.
When it happens
Trigger: Any `fetch(`${hub}/v1/sync/changes?since=...&limit=500`)` response with `res.ok === false`: 401/403 for a bad or rotated device token, 400 for a malformed `since` cursor, 404 after a hub epoch reset wiped the cursor, 429 rate limiting, or 5xx from an unhealthy hub worker.
Common situations: Device credentials expired or were rotated in the hub config; the `since` cursor references a seq trimmed by the server (epoch bump / compaction); Cloudflare Worker upstream timeouts; or the canary is pointed at the wrong hub URL via args.
Understand the failure class
Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.
Related errors
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/89f1c1915a9daddf.
Report an issue: GitHub.
Appendix: source
Thrown at workers/sync-hub/canary/canary.ts:261
acked: Array<{ operation_sha256: string; seq: string }>;
head_seq: string;
};
const ack = parsed.acked.find((a) => a.operation_sha256 === operationSha256);
if (!ack) throw new Error("push response did not ack the canary op");
return { seq: ack.seq, headSeq: parsed.head_seq, opSha: operationSha256 };
}
private async pull(
deviceId: string,
since: string,
): Promise<{ ops: Array<{ seq: string; operation_sha256: string }>; headSeq: string }> {
const res = await fetch(`${this.args.hub}/v1/sync/changes?since=${since}&limit=500`, {
headers: this.headers(deviceId),
signal: AbortSignal.timeout(this.args.timeoutMs),
});
this.noteMode(res);
if (!res.ok) {
throw new Error(`pull ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
}
const parsed = (await res.json()) as {
ops: Array<{ seq: string; operation_sha256: string }>;
head_seq: string;
};
return { ops: parsed.ops, headSeq: parsed.head_seq };
}
/** Start both cursors at the current head — only NEW ops matter. */
async init(): Promise<void> {
const res = await fetch(`${this.args.hub}/v1/sync/status`, {
headers: this.headers(DEVICE_A),
signal: AbortSignal.timeout(this.args.timeoutMs),
});
this.noteMode(res);
if (!res.ok) {
throw new Error(`status ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
}View on GitHub (pinned to d8bc9755e7)