From 2435a35a6130c86d5f91b9364eca141a288bc59d Mon Sep 17 00:00:00 2001 From: vswaroop04 Date: Fri, 22 May 2026 01:46:15 +0530 Subject: [PATCH] fix(sync): surface error context on sync run failures (closes #139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sync run fails, the result now includes a structured error object with message, httpStatus, and retryAfter — rather than an opaque status: "failed" with no diagnostic context. This lets agents and operators distinguish a 429 rate-limit from a 401 auth failure from a network error without parsing the message string. The runner attaches _httpStatus and _retryAfter from ApiError before re-throwing so the command layer can forward them. SyncRunError is introduced in types.ts so callers have a typed surface to match on. Human-readable output also prints the HTTP status and retry hint when present. --- package.json | 2 +- src/lib/memory/sync/index.ts | 14 +++++++++++--- src/lib/memory/sync/runner.ts | 9 +++++++++ src/lib/memory/sync/types.ts | 10 ++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index fe9a224..9efb94f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "1.43.4", + "version": "1.43.5", "description": "CLI for managing One", "type": "module", "files": [ diff --git a/src/lib/memory/sync/index.ts b/src/lib/memory/sync/index.ts index 315e164..6a9ed5d 100644 --- a/src/lib/memory/sync/index.ts +++ b/src/lib/memory/sync/index.ts @@ -610,13 +610,18 @@ async function syncRunCommand(platform: string, options: SyncRunOptions): Promis } catch (err) { // Use real counts attached by the runner (if available) instead of 0 const errObj = err as any; + const errorContext: { message: string; httpStatus?: number; retryAfter?: number } = { + message: err instanceof Error ? err.message : String(err), + ...(errObj?._httpStatus !== undefined ? { httpStatus: errObj._httpStatus } : {}), + ...(errObj?._retryAfter !== undefined ? { retryAfter: errObj._retryAfter } : {}), + }; results.push({ model: profile.model, recordsSynced: errObj?._recordsSynced ?? 0, pagesProcessed: errObj?._pagesProcessed ?? 0, duration: '0s', status: 'failed', - error: err instanceof Error ? err.message : String(err), + error: errorContext, }); } } @@ -643,8 +648,11 @@ async function syncRunCommand(platform: string, options: SyncRunOptions): Promis const archivedColor = sc.archived > sc.active ? pc.red : pc.dim; console.log(` memory: ${pc.green(String(sc.active))} active, ${archivedColor(String(sc.archived))} archived`); } - if ('error' in r && r.error) { - console.log(` ${pc.red(r.error as string)}`); + if (r.error) { + const errParts = [r.error.message]; + if (r.error.httpStatus) errParts.push(`HTTP ${r.error.httpStatus}`); + if (r.error.retryAfter) errParts.push(`retry after ${r.error.retryAfter}s`); + console.log(` ${pc.red(errParts.join(' — '))}`); } } } diff --git a/src/lib/memory/sync/runner.ts b/src/lib/memory/sync/runner.ts index 7a0f9b9..fcc1596 100644 --- a/src/lib/memory/sync/runner.ts +++ b/src/lib/memory/sync/runner.ts @@ -847,6 +847,11 @@ export async function syncModel( const rawMsg = err instanceof Error ? err.message : String(err); const shortMsg = truncate(rawMsg, 500); + // Propagate HTTP-level context so callers can distinguish 429/401/5xx + // from network failures without parsing the message string. + const httpStatus = err instanceof ApiError ? err.status : undefined; + const retryAfter = err instanceof ApiError ? err.retryAfterSeconds : undefined; + if (pagesProcessed > 0) { const resumeErr = new Error( `Sync interrupted after page ${pagesProcessed} (${totalRecords} records). ` + @@ -855,6 +860,8 @@ export async function syncModel( // Attach real counts so callers can report progress (resumeErr as any)._recordsSynced = totalRecords; (resumeErr as any)._pagesProcessed = pagesProcessed; + (resumeErr as any)._httpStatus = httpStatus; + (resumeErr as any)._retryAfter = retryAfter; throw resumeErr; } @@ -862,6 +869,8 @@ export async function syncModel( const wrapped = new Error(shortMsg); (wrapped as any)._recordsSynced = totalRecords; (wrapped as any)._pagesProcessed = pagesProcessed; + (wrapped as any)._httpStatus = httpStatus; + (wrapped as any)._retryAfter = retryAfter; throw wrapped; } finally { process.off('SIGINT', onSigint); diff --git a/src/lib/memory/sync/types.ts b/src/lib/memory/sync/types.ts index ce43c38..b10be4d 100644 --- a/src/lib/memory/sync/types.ts +++ b/src/lib/memory/sync/types.ts @@ -180,12 +180,22 @@ export interface ModelSyncState { export type SyncState = Record>; +export interface SyncRunError { + message: string; + /** HTTP status code when the failure originated from an API response (e.g. 429, 401). */ + httpStatus?: number; + /** Seconds until retry is safe, parsed from Retry-After (set when httpStatus is 429). */ + retryAfter?: number; +} + export interface SyncRunResult { model: string; recordsSynced: number; pagesProcessed: number; duration: string; status: 'complete' | 'failed' | 'dry-run'; + /** Populated when status is "failed" — structured error context for programmatic handling. */ + error?: SyncRunError; /** Rows removed by --full-refresh because they were no longer in the source. */ deletedStale?: number; /** Whether --full-refresh actually ran reconcile (skipped on truncated pagination). */