diff --git a/bindings/electron/native/addon.cpp b/bindings/electron/native/addon.cpp index cb72355794..d8dc9bb49c 100644 --- a/bindings/electron/native/addon.cpp +++ b/bindings/electron/native/addon.cpp @@ -58,6 +58,7 @@ #include "voice_bridge.h" #include "rac/core/rac_types.h" +#include "rac/core/rac_error_proto.h" #include "rac/features/diarization/rac_diarization_service.h" #include "rac/features/diarization/rac_diarization_types.h" #include "rac/features/embeddings/rac_embeddings_service.h" @@ -261,18 +262,40 @@ rac_handle_t take_handle_when_idle(std::unordered_map& ma return h; } -int rac_code_abs(rac_result_t code) { - int value = static_cast(code); +/** Return a code magnitude without overflowing for INT32_MIN. */ +int64_t rac_code_abs(rac_result_t code) { + int64_t value = static_cast(code); return value < 0 ? -value : value; } -// Build a structured JS Error for a rac_result_t so the TS layer can recover a -// typed SDKException without depending on string parsing alone. -Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& message) { +std::string current_rac_error_details() { + const char* details = rac_error_get_details(); + return details ? details : ""; +} + +/** Build a native error with canonical proto details and the operation message. */ +Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& message, + const std::string* worker_details = nullptr) { Napi::Error error = Napi::Error::New(env, message); Napi::Object value = error.Value(); value.Set("code", Napi::Number::New(env, rac_code_abs(code))); // canonical positive ErrorCode value.Set("cAbiCode", Napi::Number::New(env, static_cast(code))); // raw rac_result_t + rac_proto_buffer_t buffer; + rac_proto_buffer_init(&buffer); + const std::string previous_details = worker_details ? current_rac_error_details() : ""; + if (worker_details) + rac_error_set_details(worker_details->empty() ? nullptr : worker_details->c_str()); + // Only attach sdkError for genuine failures; rac_result_to_proto_error serializes a + // success proto when code == RAC_SUCCESS (no skip-on-success branch in commons), which + // would make asSDKException() see a success payload and misreport the error category. + if (code != RAC_SUCCESS && + rac_result_to_proto_error(code, &buffer) == RAC_SUCCESS && buffer.data != nullptr && + buffer.size > 0) { + value.Set("sdkError", Napi::Buffer::Copy(env, buffer.data, buffer.size)); + } + if (worker_details) + rac_error_set_details(previous_details.empty() ? nullptr : previous_details.c_str()); + rac_proto_buffer_free(&buffer); return error; } @@ -847,6 +870,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { const rac_result_t transport_rc = rac_desktop_http_transport_register(); if (transport_rc != RAC_SUCCESS) { code_ = transport_rc; + details_ = current_rac_error_details(); err_ = "desktop HTTP/device bootstrap failed: " + std::to_string(transport_rc); return; } @@ -904,6 +928,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { rac_proto_buffer_free(&p1out); if (rc != RAC_SUCCESS) { code_ = rc; + details_ = current_rac_error_details(); err_ = "sdk_init_phase1 failed: " + std::to_string(rc); return; } @@ -914,6 +939,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { if (rc != RAC_SUCCESS) { rac_proto_buffer_free(&p2out); code_ = rc; + details_ = current_rac_error_details(); err_ = "sdk_init_phase2 failed: " + std::to_string(rc); return; } @@ -929,7 +955,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { if (ok_) { deferred_.Resolve(Napi::Buffer::Copy(Env(), result_.data(), result_.size())); } else { - deferred_.Reject(make_rac_error(Env(), code_, err_).Value()); + deferred_.Reject(make_rac_error(Env(), code_, err_, &details_).Value()); } } @@ -945,6 +971,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { std::string app_identifier_, app_name_, app_version_; std::vector phase1_, phase2_, result_; std::string err_; + std::string details_; bool ok_ = false; rac_result_t code_ = RAC_SUCCESS; }; @@ -1067,6 +1094,7 @@ struct StreamCtx { Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; std::string error_msg; + std::string error_details; std::function run; // performs the rac streaming call int32_t lease_id = 0; // begin_op id; end_op when the worker finishes bool leased = false; @@ -1177,7 +1205,7 @@ Napi::Promise start_stream(Napi::Env env, Napi::Function on_token, std::string msg = c->error_msg.empty() ? ("stream failed: " + std::to_string(c->result)) : c->error_msg; - c->deferred.Reject(make_rac_error(env, c->result, msg).Value()); + c->deferred.Reject(make_rac_error(env, c->result, msg, &c->error_details).Value()); } delete c; }, @@ -1193,6 +1221,8 @@ Napi::Promise start_stream(Napi::Env env, Napi::Function on_token, } if (rc != RAC_SUCCESS && ctx->result == RAC_SUCCESS) ctx->result = rc; + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); if (ctx->leased) { end_op(ctx->lease_id); ctx->leased = false; @@ -2214,6 +2244,7 @@ struct SttStreamCtx { std::thread worker; Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; + std::string error_details; int32_t lease_id = 0; bool leased = false; std::shared_ptr gate; @@ -2278,7 +2309,8 @@ Napi::Value TranscribeStream(const Napi::CallbackInfo& info) { } else { c->deferred.Reject( make_rac_error(env, c->result, - "transcribe stream failed: " + std::to_string(c->result)) + "transcribe stream failed: " + std::to_string(c->result), + &c->error_details) .Value()); } delete c; @@ -2290,6 +2322,8 @@ Napi::Value TranscribeStream(const Napi::CallbackInfo& info) { ctx->result = rac_stt_component_transcribe_stream( ctx->handle, ctx->audio.data(), ctx->audio.size(), &ctx->opts.opts, stt_stream_cb, ctx); + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); } if (ctx->leased) { end_op(ctx->lease_id); @@ -2496,6 +2530,7 @@ struct TtsStreamCtx { std::thread worker; Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; + std::string error_details; int32_t lease_id = 0; bool leased = false; std::shared_ptr gate; @@ -2561,7 +2596,8 @@ Napi::Value SynthesizeStream(const Napi::CallbackInfo& info) { } else { c->deferred.Reject( make_rac_error(env, c->result, - "synthesize stream failed: " + std::to_string(c->result)) + "synthesize stream failed: " + std::to_string(c->result), + &c->error_details) .Value()); } delete c; @@ -2572,6 +2608,8 @@ Napi::Value SynthesizeStream(const Napi::CallbackInfo& info) { std::lock_guard serialize(*ctx->gate); ctx->result = rac_tts_component_synthesize_stream( ctx->handle, ctx->text.c_str(), &ctx->opts.opts, tts_stream_cb, ctx); + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); } if (ctx->leased) { end_op(ctx->lease_id); @@ -3585,18 +3623,21 @@ Napi::Value UnloadSegmentationModel(const Napi::CallbackInfo& info) { // RAGResult/RAGStatistics in an owned rac_proto_buffer_t that we copy into a // Napi::Buffer and free. -// Copy an owned proto-out buffer to a JS Buffer (throwing on failure), always -// releasing the native buffer. +/** Copy an owned proto buffer to JS, preserving failure status before freeing it. */ static Napi::Value rag_out_to_js(Napi::Env env, rac_proto_buffer_t* buf, const char* what) { if (buf->status != RAC_SUCCESS || buf->data == nullptr) { - std::string msg = std::string(what) + " failed: " + std::to_string(buf->status); + // Capture status and message before freeing: rac_proto_buffer_free resets + // buf->status to RAC_SUCCESS via release_fields/reset_fields, so reading + // buf->status after the free would always report success. + rac_result_t status = buf->status; + std::string msg = std::string(what) + " failed: " + std::to_string(status); if (buf->error_message) { msg += " ("; msg += buf->error_message; msg += ")"; } rac_proto_buffer_free(buf); - make_rac_error(env, buf->status, msg).ThrowAsJavaScriptException(); + make_rac_error(env, status, msg).ThrowAsJavaScriptException(); return env.Undefined(); } Napi::Buffer out = Napi::Buffer::Copy(env, buf->data, buf->size); @@ -3659,6 +3700,13 @@ class RagProtoWorker : public Napi::AsyncWorker { rac_result_t rc = op_(session_, input_.data(), input_.size(), &out); if (rc != RAC_SUCCESS && out.status == RAC_SUCCESS) out.status = rc; + if (out.status != RAC_SUCCESS) { + if (out.error_message && out.error_message[0] != '\0') { + details_ = out.error_message; + } else { + details_ = current_rac_error_details(); + } + } if (out.status == RAC_SUCCESS && out.data != nullptr) { result_.assign(out.data, out.data + out.size); ok_ = true; @@ -3679,7 +3727,7 @@ class RagProtoWorker : public Napi::AsyncWorker { if (ok_) { deferred_.Resolve(Napi::Buffer::Copy(Env(), result_.data(), result_.size())); } else { - deferred_.Reject(make_rac_error(Env(), code_, err_).Value()); + deferred_.Reject(make_rac_error(Env(), code_, err_, &details_).Value()); } } @@ -3707,6 +3755,7 @@ class RagProtoWorker : public Napi::AsyncWorker { std::string what_; std::vector result_; std::string err_; + std::string details_; bool ok_ = false; rac_result_t code_ = RAC_SUCCESS; }; @@ -3806,6 +3855,7 @@ class RagCreateSessionWorker : public Napi::AsyncWorker { rac_result_t rc = rac_rag_session_create_proto(config_.data(), config_.size(), &session_); code_ = rc; if (rc != RAC_SUCCESS || session_ == nullptr) { + details_ = current_rac_error_details(); err_ = "rag session create failed: " + std::to_string(rc); session_ = nullptr; ok_ = false; @@ -3817,7 +3867,7 @@ class RagCreateSessionWorker : public Napi::AsyncWorker { void OnOK() override { Napi::HandleScope scope(Env()); if (!ok_ || session_ == nullptr) { - deferred_.Reject(make_rac_error(Env(), code_, err_).Value()); + deferred_.Reject(make_rac_error(Env(), code_, err_, &details_).Value()); return; } int32_t hid; @@ -3859,6 +3909,7 @@ class RagCreateSessionWorker : public Napi::AsyncWorker { std::vector config_; rac_handle_t session_ = nullptr; std::string err_; + std::string details_; bool ok_ = false; rac_result_t code_ = RAC_SUCCESS; }; @@ -3903,6 +3954,7 @@ struct RagStreamCtx { std::thread worker; Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; + std::string error_details; int32_t lease_id = 0; bool leased = false; rac_handle_t session = nullptr; @@ -3954,7 +4006,8 @@ Napi::Value RagQueryStream(const Napi::CallbackInfo& info) { } else { c->deferred.Reject( make_rac_error(env, c->result, - "rag query stream failed: " + std::to_string(c->result)) + "rag query stream failed: " + std::to_string(c->result), + &c->error_details) .Value()); } delete c; @@ -3963,6 +4016,8 @@ Napi::Value RagQueryStream(const Napi::CallbackInfo& info) { ctx->worker = std::thread([ctx]() { ctx->result = rac_rag_query_stream_proto(ctx->session, ctx->query.data(), ctx->query.size(), rag_stream_cb, ctx); + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); if (ctx->leased) { end_op(ctx->lease_id); ctx->leased = false; diff --git a/bindings/electron/src/errors.ts b/bindings/electron/src/errors.ts index 971b613a0d..a1e2df46b8 100644 --- a/bindings/electron/src/errors.ts +++ b/bindings/electron/src/errors.ts @@ -63,6 +63,8 @@ export const ErrorCategories: Readonly> ) as Readonly>; export interface SDKErrorFields { + /** Serialized proto retained when a native error crosses utility-process RPC. */ + sdkError?: Uint8Array; code: ErrorCode; message: string; category?: ErrorCategory; @@ -85,6 +87,7 @@ export interface SDKErrorFields { * Carries the canonical `code` / `category` for cross-SDK-uniform handling. */ export class SDKException extends Error { + readonly sdkError?: Uint8Array; readonly code: ErrorCode; readonly category: ErrorCategory; /** Negative rac_result_t equivalent, when applicable. */ @@ -105,6 +108,7 @@ export class SDKException extends Error { constructor(fields: SDKErrorFields) { super(fields.message || 'SDK error'); this.name = 'SDKException'; + this.sdkError = fields.sdkError; this.code = fields.code; this.category = fields.category ?? categoryForCode(fields.code); this.cAbiCode = @@ -149,7 +153,7 @@ export class SDKException extends Error { } /** Build from a decoded proto `SDKError` payload (the D5 structured-error field). */ - static fromProto(error: SDKError): SDKException { + static fromProto(error: SDKError, sdkError?: Uint8Array): SDKException { return fromStructuredError({ code: error.code, message: error.message, @@ -162,7 +166,8 @@ export class SDKException extends Error { requestId: error.requestId, severity: error.severity, timestampMs: error.timestampMs, - }); + sdkError: sdkError ?? SDKError.encode(error).finish(), + }, true); } static notInitialized(component?: string): SDKException { @@ -269,10 +274,23 @@ type ErrorLike = { * `rac_result_to_proto_error`, so when it is present the shared `categoryForCode` * table is not consulted at all. */ -function fromSerializedSdkError(value: unknown): SDKException | null { +function fromSerializedSdkError(value: unknown, contextualMessage?: string): SDKException | null { if (!(value instanceof Uint8Array) || value.length === 0) return null; try { - return SDKException.fromProto(SDKError.decode(value)); + const proto = SDKError.decode(value); + // Keep the native bytes untouched: they may contain fields this SDK does + // not know yet. Context belongs on the display exception, not in the wire + // payload. If it already contains the proto message, this is a second decode + // of an RPC error and adding the suffix again would duplicate it. + const exception = SDKException.fromProto(proto, value); + if (contextualMessage && !contextualMessage.includes(proto.message)) { + exception.message = proto.message + ? `${contextualMessage} (${proto.message})` + : contextualMessage; + } else if (contextualMessage) { + exception.message = contextualMessage; + } + return exception; } catch { return null; } @@ -300,8 +318,8 @@ const KNOWN_ERROR_CODES: ReadonlySet = new Set( * counterpart) still reports `UNKNOWN`, with the raw value readable on * `cAbiCode`, because typing it as an `ErrorCode` would be a lie. * - * A category is only derived when the wire supplied none — proto3 leaves an - * unset enum at 0, so `UNSPECIFIED` counts as unset. + * Serialized proto categories are authoritative, including UNSPECIFIED. Loose + * legacy fields still derive a category when their category is absent or zero. */ function fromStructuredError(args: { code: number; @@ -315,18 +333,25 @@ function fromStructuredError(args: { requestId?: string; severity?: number; timestampMs?: number; -}): SDKException { + sdkError?: Uint8Array; +}, authoritativeCategory = false): SDKException { const magnitude = Math.abs(Math.trunc(args.code)); const code = (KNOWN_ERROR_CODES.has(magnitude) ? magnitude : ErrorCode.ERROR_CODE_UNKNOWN) as ErrorCode; - const wireCategory = - args.category != null && Math.trunc(args.category) !== ErrorCategory.ERROR_CATEGORY_UNSPECIFIED - ? (Math.trunc(args.category) as ErrorCategory) - : undefined; + const knownCategory = args.category != null && Number.isInteger(args.category) && + args.category >= ErrorCategory.ERROR_CATEGORY_UNSPECIFIED && + args.category <= ErrorCategory.ERROR_CATEGORY_CONFIGURATION + ? args.category as ErrorCategory + : ErrorCategory.ERROR_CATEGORY_UNSPECIFIED; + const category = authoritativeCategory || + (args.category != null && args.category !== ErrorCategory.ERROR_CATEGORY_UNSPECIFIED) + ? knownCategory + : categoryForCode(magnitude); return new SDKException({ code, message: args.message, cAbiCode: args.cAbiCode, - category: wireCategory ?? categoryForCode(code), + category, + sdkError: args.sdkError, nestedMessage: args.nestedMessage, fieldPath: args.fieldPath, component: args.component, @@ -367,7 +392,8 @@ export function asSDKException(error: unknown): SDKException { if (error instanceof SDKException) return error; if (error && typeof error === 'object') { const candidate = error as ErrorLike; - const structured = fromSerializedSdkError(candidate.sdkError); + const contextualMessage = stringValue(candidate.message); + const structured = fromSerializedSdkError(candidate.sdkError, contextualMessage); if (structured) return structured; const message = stringValue(candidate.message) ?? diff --git a/bindings/electron/src/process/dispatch.ts b/bindings/electron/src/process/dispatch.ts index 02ef4a8969..3ed4229789 100644 --- a/bindings/electron/src/process/dispatch.ts +++ b/bindings/electron/src/process/dispatch.ts @@ -117,6 +117,7 @@ export const ALLOWED_RPC_METHODS: ReadonlySet = new Set([ ...BACKEND_METHODS.map(rpcMethodFor), ]); +/** Retain native SDK error fields and proto bytes across the utility-process boundary. */ function rpcError(e: unknown): string | RpcErrorPayload { if (typeof e === 'string') return e; if (e instanceof Error) { @@ -124,6 +125,7 @@ function rpcError(e: unknown): string | RpcErrorPayload { code?: unknown; cAbiCode?: unknown; category?: unknown; + sdkError?: unknown; nestedMessage?: unknown; fieldPath?: unknown; }; @@ -137,6 +139,7 @@ function rpcError(e: unknown): string | RpcErrorPayload { ...(typeof extra.category === 'number' && Number.isFinite(extra.category) ? { category: extra.category } : {}), + ...(extra.sdkError instanceof Uint8Array ? { sdkError: extra.sdkError } : {}), ...(typeof extra.nestedMessage === 'string' ? { nestedMessage: extra.nestedMessage } : {}), ...(typeof extra.fieldPath === 'string' ? { fieldPath: extra.fieldPath } : {}), }; @@ -154,6 +157,7 @@ function rpcError(e: unknown): string | RpcErrorPayload { ...(typeof obj.category === 'number' && Number.isFinite(obj.category) ? { category: obj.category } : {}), + ...(obj.sdkError instanceof Uint8Array ? { sdkError: obj.sdkError } : {}), ...(typeof obj.nestedMessage === 'string' ? { nestedMessage: obj.nestedMessage } : {}), ...(typeof obj.fieldPath === 'string' ? { fieldPath: obj.fieldPath } : {}), }; diff --git a/bindings/electron/src/process/rpc.ts b/bindings/electron/src/process/rpc.ts index fa2aa0eb98..87941176b9 100644 --- a/bindings/electron/src/process/rpc.ts +++ b/bindings/electron/src/process/rpc.ts @@ -16,6 +16,7 @@ export interface RpcErrorPayload { code?: number; cAbiCode?: number; category?: number; + sdkError?: Uint8Array; nestedMessage?: string; fieldPath?: string; } diff --git a/bindings/electron/test/unit/dispatch.test.ts b/bindings/electron/test/unit/dispatch.test.ts index cade199dc2..46721eaf72 100644 --- a/bindings/electron/test/unit/dispatch.test.ts +++ b/bindings/electron/test/unit/dispatch.test.ts @@ -1,5 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { SDKError, ErrorSeverity } from '@runanywhere/proto-ts/errors'; +import { asSDKException, ErrorCodes, ErrorCategories } from '../../dist/errors'; import * as os from 'os'; import * as path from 'path'; @@ -372,6 +374,46 @@ test('a structured native Error keeps numeric SDK fields over RPC', async () => ]); }); +test('a structured native Error keeps serialized sdkError bytes over RPC', async () => { + const port = makePort(); + const sdkError = SDKError.encode(SDKError.fromPartial({ + code: ErrorCodes.MODEL_LOAD_FAILED, category: ErrorCategories.MODEL, + cAbiCode: -111, message: 'Model load failed', nestedMessage: 'backend cause', + param: 'model', component: 'rag', retryable: true, requestId: 'request-1', + severity: ErrorSeverity.ERROR_SEVERITY_ERROR, timestampMs: 123, + })).finish(); + // bridge.wrapNative converts native errors before dispatch sees them. + const err = asSDKException(Object.assign(new Error('load_model failed: -111'), { sdkError })); + const deps = makeDeps({ + api: { + embed: () => { + throw err; + }, + }, + }); + + dispatch(port, { id: 24, method: 'embed', args: [] }, deps); + await tick(); + + assert.equal(port.posts.length, 1); + assert.equal(port.posts[0]?.id, 24); + assert.equal(port.posts[0]?.ok, false); + const payload = port.posts[0]?.error; + assert.ok(payload && typeof payload === 'object'); + const received = asSDKException(payload); + assert.equal(received.message, 'load_model failed: -111 (Model load failed)'); + assert.equal(received.code, ErrorCodes.MODEL_LOAD_FAILED); + assert.equal(received.category, ErrorCategories.MODEL); + assert.equal(received.cAbiCode, -111); + assert.equal(received.nestedMessage, 'backend cause'); + assert.equal(received.fieldPath, 'model'); + assert.equal(received.component, 'rag'); + assert.equal(received.retryable, true); + assert.equal(received.requestId, 'request-1'); + assert.equal(received.severity, ErrorSeverity.ERROR_SEVERITY_ERROR); + assert.equal(received.timestampMs, 123); +}); + test('a non-Error thrown value is String()-ified', async () => { const port = makePort(); const deps = makeDeps({ diff --git a/bindings/electron/test/unit/errors.test.ts b/bindings/electron/test/unit/errors.test.ts index 7159667063..73f48a629b 100644 --- a/bindings/electron/test/unit/errors.test.ts +++ b/bindings/electron/test/unit/errors.test.ts @@ -1,6 +1,7 @@ // Unit tests for SDKException — the house-uniform throwable (src/errors.ts). import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { SDKError } from '@runanywhere/proto-ts/errors'; import { SDKException, @@ -37,13 +38,18 @@ test('categoryForCode maps ranges like the canonical table', () => { assert.equal(categoryForCode(0), ErrorCategories.UNSPECIFIED); assert.equal(categoryForCode(100), ErrorCategories.CONFIGURATION); assert.equal(categoryForCode(110), ErrorCategories.MODEL); - // Generation (-130..-149) is in none of the table's ranges, so it takes the - // INTERNAL fallback — exactly as commons' rac_result_to_proto_category does. + // Generation (-130..-149) is explicitly INTERNAL in the shared table. assert.equal(categoryForCode(130), ErrorCategories.INTERNAL); assert.equal(categoryForCode(182), ErrorCategories.IO); assert.equal(categoryForCode(259), ErrorCategories.VALIDATION); assert.equal(categoryForCode(380), ErrorCategories.INTERNAL); + assert.equal(categoryForCode(351), ErrorCategories.IO); + assert.equal(categoryForCode(371), ErrorCategories.VALIDATION); + assert.equal(categoryForCode(401), ErrorCategories.COMPONENT); + assert.equal(categoryForCode(501), ErrorCategories.CONFIGURATION); + assert.equal(categoryForCode(601), ErrorCategories.COMPONENT); assert.equal(categoryForCode(804), ErrorCategories.INTERNAL); + assert.equal(categoryForCode(1000), ErrorCategories.UNSPECIFIED); }); test('SDKException is an Error subclass with the right name', () => { @@ -157,9 +163,52 @@ test('asSDKException prefers structured native fields over UNKNOWN', () => { assert.equal(e.code, ErrorCodes.MODEL_LOAD_FAILED); assert.equal(e.cAbiCode, -111); assert.equal(e.category, ErrorCategories.MODEL); + for (const [wireMessage, context, expected] of [ + ['Model load failed', 'ragClear failed: -111 (backend detail)', 'ragClear failed: -111 (backend detail) (Model load failed)'], + ['', 'operation detail', 'operation detail'], + ['same detail', 'same detail', 'same detail'], + ['wire detail', '', 'wire detail'], + ]) { + const sdkError = SDKError.encode(SDKError.fromPartial({ + code: ErrorCodes.MODEL_LOAD_FAILED, + category: ErrorCategories.MODEL, + cAbiCode: -111, + message: wireMessage, + nestedMessage: 'backend cause', + param: 'model', + })).finish(); + const structured = asSDKException({ message: context, sdkError }); + assert.equal(structured.message, expected); + assert.equal(structured.code, ErrorCodes.MODEL_LOAD_FAILED); + assert.equal(structured.category, ErrorCategories.MODEL); + assert.equal(structured.cAbiCode, -111); + assert.equal(structured.nestedMessage, 'backend cause'); + assert.equal(structured.fieldPath, 'model'); + assert.deepEqual(structured.sdkError, sdkError); + assert.equal(asSDKException(structured).message, expected); + } }); test('asSDKException maps trailing rac codes in native strings', () => { + for (const code of [1, 99, 390, 399, 1000, 9999, 12345]) { + const sdkError = SDKError.encode(SDKError.fromPartial({ + code, category: ErrorCategories.UNSPECIFIED, cAbiCode: -code, + message: 'unmapped native code', + })).finish(); + const decoded = asSDKException({ sdkError }); + assert.equal(decoded.category, ErrorCategories.UNSPECIFIED); + assert.equal(decoded.cAbiCode, -code); + assert.equal(asSDKException({ code, cAbiCode: -code }).category, ErrorCategories.UNSPECIFIED); + } + for (const category of [ErrorCategories.UNSPECIFIED, -1, 999]) { + const decoded = SDKException.fromProto(SDKError.fromPartial({ + code: ErrorCodes.MODEL_LOAD_FAILED, category, message: 'future category', + })); + assert.equal(decoded.category, ErrorCategories.UNSPECIFIED); + if (category !== ErrorCategories.UNSPECIFIED) { + assert.equal(asSDKException({ code: 111, category }).category, ErrorCategories.UNSPECIFIED); + } + } const e = asSDKException('stream failed: -130'); assert.equal(e.code, ErrorCodes.GENERATION_FAILED); assert.equal(e.cAbiCode, -130); diff --git a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt index c6ba0fc7a6..59a721bbcd 100644 --- a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt +++ b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt @@ -5,8 +5,6 @@ package com.runanywhere.sdk.foundation.bridge.extensions -import ai.runanywhere.proto.v1.ErrorCategory -import ai.runanywhere.proto.v1.ErrorCode import ai.runanywhere.proto.v1.RAGConfiguration import ai.runanywhere.proto.v1.RAGDocument import ai.runanywhere.proto.v1.RAGQueryOptions @@ -237,32 +235,15 @@ object CppBridgeRAG { private fun requireSession(): Long = sessionHandle.takeIf { it != 0L } ?: throw SDKException.notInitialized("RAG session not created") + /** Reuse canonical native error conversion, including its serialization-failure fallback. */ private fun createSessionException(rc: Int): SDKException { if (rc == RunAnywhereBridge.RAC_SUCCESS) { return SDKException.operation("racRagSessionCreateProto returned 0") } - val magnitude = if (rc < 0) -rc else rc - val code = ErrorCode.fromValue(magnitude) ?: ErrorCode.ERROR_CODE_UNKNOWN - return SDKException.make( - code = code, - message = "RAG proto session create failed: $rc", - category = categoryForRacResult(magnitude), - cAbiCode = rc, - ) + return SDKException.fromRACResult(rc) + ?: SDKException.operation("RAG proto session create failed: $rc") } - private fun categoryForRacResult(magnitude: Int): ErrorCategory = - when (magnitude) { - in 110..129 -> ErrorCategory.ERROR_CATEGORY_MODEL - in 150..179 -> ErrorCategory.ERROR_CATEGORY_NETWORK - in 180..219, in 330..369 -> ErrorCategory.ERROR_CATEGORY_IO - in 250..279 -> ErrorCategory.ERROR_CATEGORY_VALIDATION - in 320..329 -> ErrorCategory.ERROR_CATEGORY_AUTH - in 100..109 -> ErrorCategory.ERROR_CATEGORY_CONFIGURATION - in 400..499, in 600..999 -> ErrorCategory.ERROR_CATEGORY_INTERNAL - else -> ErrorCategory.ERROR_CATEGORY_COMPONENT - } - private fun > decodeOrThrow( adapter: ProtoAdapter, bytes: ByteArray?, diff --git a/bindings/proto-ts/AGENTS.md b/bindings/proto-ts/AGENTS.md index 88a25f6d3b..3186e55efd 100644 --- a/bindings/proto-ts/AGENTS.md +++ b/bindings/proto-ts/AGENTS.md @@ -84,7 +84,7 @@ adding the matching `exports` entry, or `moduleResolution: node16` consumers get | File | Why it's hand-written | |---|---| | `convenience/_errors.ts` | Defines `ValidationError`, the typed exception the *generated* `validate()` helpers throw. Its shape (`code`/`category`/`fieldPath`/`message`) is byte-isomorphic with the proto-backed `SDKException` Swift/Kotlin/Dart throw — see `idl/codegen/CONVENIENCE_CODEGEN_DESIGN.md §9.1`. | -| `convenience/errors_category.ts` | The `ErrorCode` → `ErrorCategory` numeric-range table. The fold exists only as C++ control flow (`rac::foundation::rac_result_to_proto_category` in `core/src/foundation/rac_proto_adapters.cpp`), not as proto annotations, so there is nothing for codegen to read. This TS table intentionally covers more ranges (up to 999) than commons currently maps (100–329, falls through to INTERNAL) — do not "fix" it down to match commons; that's a known, deliberately deferred commons gap. | +| `convenience/errors_category.ts` | The shared `ErrorCode` → `ErrorCategory` numeric-range table. It mirrors the 18 canonical ranges declared in `core/include/rac/core/rac_error_categories.def`, which commons compiles into `rac::foundation::rac_result_to_proto_category`; non-negative and unmapped codes return `UNSPECIFIED`. This TypeScript file is hand-written and no TS generator consumes the manifest: update this table manually alongside the manifest whenever ranges change, then regenerate generated consumers such as Python's category helper. Web, React Native, and Electron all consume this table. | | `events/public_events.ts` | The v4 public streaming-event discriminated unions (`GenerationEvent`, `TranscriptionEvent`, `VoiceEvent`, `DownloadEvent`, etc.) shared by Web/RN/Electron. Payload slots are generic type parameters so each SDK binds its own local result/match/segment types without forking the discriminant arms. Deprecated RN-only arms live in separate `*WithDeprecated` aliases — new SDKs must not re-export those. | | `streams/_streamFactory.ts` | `streamFactory()`: adapts a subscribe-based transport into a lazy `AsyncIterable`. Generated `streams/*_service_stream.ts` wrappers import this; `idl/codegen/generate_streams.sh` only ever writes the `*_service_stream.ts` files, never this one. | | `streams/push.ts` | Push→`AsyncIterable` adapters (`pushStream`, `iterableFromSubscription`, `mapStream`, `deferStream`, `forEachStream`, `AsyncQueue`, ...) used across Web/RN/Electron. Three constraints are load-bearing and apply to any addition here: lazy start (producer runs on first `next()`, not construction); cancel-on-`return()`; and Electron `contextBridge`-safety — iterators must be plain object literals with `next`/`return`/`throw`/`Symbol.asyncIterator` as **own** properties, because `contextBridge` strips prototype methods (`AsyncQueue`'s `[Symbol.asyncIterator]` is a class field for the same reason). Also Hermes-safe: nothing here requires `for await...of` (RN/Hermes can't do that over a Nitro-backed iterable — consumers use `forEachStream`/manual `iterator.next()` loops instead). | diff --git a/bindings/proto-ts/src/convenience/errors_category.ts b/bindings/proto-ts/src/convenience/errors_category.ts index bc24f4d955..22974fb544 100644 --- a/bindings/proto-ts/src/convenience/errors_category.ts +++ b/bindings/proto-ts/src/convenience/errors_category.ts @@ -4,8 +4,9 @@ * * HAND-AUTHORED — not produced by `idl/codegen/generate_ts_convenience.py`. * The fold from numeric ranges onto the 9-bucket proto `ErrorCategory` exists - * only as C++ control flow, not as proto annotations, so there is nothing for - * the IDL codegen to emit. Do not invent a second table in an SDK. + * in `core/include/rac/core/rac_error_categories.def`, shared by commons and + * Python codegen. Keep this TS fallback aligned with that manifest. + * Do not invent a second table in an SDK. * * This table is the richer 18-range mapping historically shared byte-for-byte * by Web and React Native. It deliberately covers bands beyond |329| @@ -14,10 +15,9 @@ * proto codes keep meaningful categories instead of collapsing to INTERNAL. * * C++ commons (`rac::foundation::rac_result_to_proto_category` in - * `core/src/foundation/rac_proto_adapters.cpp`) currently - * only maps |100|–|329| and falls through to INTERNAL — that is a known - * under-mapping, deliberately deferred (see `ts_shared_layer.md` Known issue). - * Do NOT “fix” this TS table down to match commons. + * `core/src/foundation/rac_proto_adapters.cpp`) uses the same 18-range table + * for serialized `SDKError` payloads. Negative values outside the mapped + * ranges return `UNSPECIFIED`. * * Callers may pass either the signed C ABI code or the positive proto * `ErrorCode` (abs magnitude); both are accepted via `Math.abs`. diff --git a/bindings/python/_build/ra_build_backend.py b/bindings/python/_build/ra_build_backend.py index 9a8ddc8789..eb13f5ec79 100644 --- a/bindings/python/_build/ra_build_backend.py +++ b/bindings/python/_build/ra_build_backend.py @@ -136,7 +136,10 @@ def _generate() -> None: def _newest_input_mtime() -> float: """Newest mtime across everything the three Python generators read.""" newest = 0.0 - for pattern in ("idl/*.proto", "idl/codegen/*.py", "idl/codegen/*.sh"): + for pattern in ( + "idl/*.proto", "idl/codegen/*.py", "idl/codegen/*.sh", + "core/include/rac/core/rac_error_categories.def", + ): for path in _REPO_ROOT.glob(pattern): try: newest = max(newest, path.stat().st_mtime) diff --git a/bindings/python/docs/reference/architecture.md b/bindings/python/docs/reference/architecture.md index 3f7e807961..e1579d7cb2 100644 --- a/bindings/python/docs/reference/architecture.md +++ b/bindings/python/docs/reference/architecture.md @@ -137,8 +137,8 @@ through the process-wide singleton `bus`, exposed as `runanywhere.events`. Subsc `errors.py` defines `SDKException` (the single throwable) carrying a canonical `code` (`ErrorCode`, exhaustive vs `idl/errors.proto`) + `category` (`ErrorCategory`) for -cross-SDK-uniform handling. `category_for_code` is a faithful port of commons -`rac_result_to_proto_category` (keep in sync). Category-specific static factories +cross-SDK-uniform handling. `category_for_code` is generated from shared 18-range +manifest (`core/include/rac/core/rac_error_categories.def`); unmapped negative values remain `UNSPECIFIED`. Category-specific static factories (`not_initialized`, `validation_failed`, `model_not_found`, `generation_failed`, `storage_error`, `invalid_state`, …) build the right code/category; `raise_for_rac(rac_code)` maps a negative `rac_result_t` back to an `ErrorCode` (preserving the raw ABI value as diff --git a/bindings/python/docs/reference/best-practices.md b/bindings/python/docs/reference/best-practices.md index 9751264593..8b48b9a4c2 100644 --- a/bindings/python/docs/reference/best-practices.md +++ b/bindings/python/docs/reference/best-practices.md @@ -24,8 +24,8 @@ applications of it:) - Public options/results are dataclasses or IntEnums — never raw string status codes. - Native error codes come from `idl/errors.proto` / `rac_error.h`. Keep `ErrorCode` / `ErrorCategory` exhaustive relative to the IDL and map categories with - `category_for_code` as a faithful port of commons `rac_result_to_proto_category` - (AUTH is only 320–329; unmapped failures → INTERNAL, not UNSPECIFIED). + `category_for_code`, generated from commons’ shared 18-range manifest (including AUTH + 320–349); unmapped negative values remain `UNSPECIFIED`. - Registry framework/category ints are **C ABI enums** (`RAC_FRAMEWORK_*`, `RAC_MODEL_CATEGORY_*`), not proto wire values. Name them and pin them in tests. - Generated RAG protos live in `runanywhere/_proto/`; regenerate via diff --git a/bindings/python/runanywhere/errors.py b/bindings/python/runanywhere/errors.py index 7621f353f5..03931f2428 100644 --- a/bindings/python/runanywhere/errors.py +++ b/bindings/python/runanywhere/errors.py @@ -4,39 +4,7 @@ from typing import Any, NoReturn -from ._generated_errors import ErrorCategory, ErrorCode - - -def category_for_code(code: int) -> ErrorCategory: - """Map an ErrorCode (positive) or ``rac_result_t`` (negative) to its ErrorCategory. - - Faithful port of commons ``rac_result_to_proto_category`` in - ``rac_proto_adapters.cpp``. Unmapped failure codes return INTERNAL (not - UNSPECIFIED). AUTH is only 320–329; security codes 330–349 fall through to - INTERNAL, matching the C ABI. - """ - # Normalize to negative rac_result_t for the commons range table. - if code > 0: - n = -code - else: - n = code - if n >= 0: - return ErrorCategory.UNSPECIFIED - if -179 <= n <= -150: - return ErrorCategory.NETWORK - if -279 <= n <= -250: - return ErrorCategory.VALIDATION - if -129 <= n <= -110: - return ErrorCategory.MODEL - if (-219 <= n <= -180) or (-299 <= n <= -280): - return ErrorCategory.IO - if -329 <= n <= -320: - return ErrorCategory.AUTH - if -109 <= n <= -100: - return ErrorCategory.CONFIGURATION - if (-249 <= n <= -230) or (-319 <= n <= -300): - return ErrorCategory.COMPONENT - return ErrorCategory.INTERNAL +from ._generated_errors import ErrorCategory, ErrorCode, category_for_code class SDKException(Exception): @@ -66,6 +34,7 @@ def __init__( nested_message: str | None = None, field_path: str | None = None, ) -> None: + """Retain explicit wire categories; derive local defaults from shared generated data.""" super().__init__(message or "SDK error") self.message = message or "SDK error" self.code = code @@ -108,6 +77,7 @@ def of( nested_message: str | None = None, field_path: str | None = None, ) -> "SDKException": + """Construct an exception with optional wire details and category overrides.""" return SDKException( code, message, @@ -132,7 +102,7 @@ def from_proto(err: Any) -> "SDKException": try: category = ErrorCategory(err.category) except ValueError: - category = None + category = ErrorCategory.UNSPECIFIED field_path = err.param if err.HasField("param") else None return SDKException.of( code, @@ -156,6 +126,7 @@ def invalid_input(details: str | None = None) -> "SDKException": return SDKException.of( ErrorCode.INVALID_INPUT, f"Invalid input: {details}" if details else "Invalid input", + category=ErrorCategory.VALIDATION, ) @staticmethod @@ -173,6 +144,7 @@ def model_not_found(model_id: str | None = None) -> "SDKException": return SDKException.of( ErrorCode.MODEL_NOT_FOUND, f"Model not found: {model_id}" if model_id else "Model not found", + category=ErrorCategory.MODEL, ) @staticmethod @@ -182,6 +154,7 @@ def model_load_failed( return SDKException.of( ErrorCode.MODEL_LOAD_FAILED, f"Failed to load model: {model_id}" if model_id else "Failed to load model", + category=ErrorCategory.MODEL, nested_message=str(cause) if cause is not None else None, ) @@ -192,6 +165,7 @@ def generation_failed( return SDKException.of( ErrorCode.GENERATION_FAILED, details if details is not None else "Generation failed", + category=ErrorCategory.INTERNAL, nested_message=str(cause) if cause is not None else None, ) @@ -199,9 +173,11 @@ def generation_failed( def storage_error( details: str | None = None, cause: BaseException | None = None ) -> "SDKException": + """Describe file/download I/O failures while retaining their nested cause.""" return SDKException.of( ErrorCode.STORAGE_ERROR, details if details is not None else "Storage error", + category=ErrorCategory.IO, nested_message=str(cause) if cause is not None else None, ) @@ -218,6 +194,7 @@ def not_implemented(feature: str | None = None) -> "SDKException": return SDKException.of( ErrorCode.NOT_IMPLEMENTED, f"{feature} not implemented" if feature else "Not implemented", + category=ErrorCategory.INTERNAL, ) @staticmethod @@ -249,6 +226,7 @@ def unknown( return SDKException.of( ErrorCode.UNKNOWN, details if details is not None else "Unknown error", + category=ErrorCategory.INTERNAL, nested_message=str(cause) if cause is not None else None, ) @@ -285,5 +263,6 @@ def raise_for_rac(rac_code: int, message: str | None = None) -> NoReturn: raise SDKException.of( code, message if message is not None else f"Native call failed (rac={rac_code})", + category=category_for_code(rac_code), c_abi_code=rac_code, ) diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index e839fc54e0..88cc268220 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -51,67 +51,76 @@ def test_error_category_values(): # --------------------------------------------------------------------------- # -# category_for_code range table (verbatim port) # +# Proto-backed category behavior # # --------------------------------------------------------------------------- # @pytest.mark.parametrize( - "code,expected", + ("first", "last", "category"), [ - (0, ErrorCategory.UNSPECIFIED), - (100, ErrorCategory.CONFIGURATION), - (109, ErrorCategory.CONFIGURATION), - (110, ErrorCategory.MODEL), - (111, ErrorCategory.MODEL), - (129, ErrorCategory.MODEL), - # Generation range is not named in commons → INTERNAL - (130, ErrorCategory.INTERNAL), - (149, ErrorCategory.INTERNAL), - (150, ErrorCategory.NETWORK), - (179, ErrorCategory.NETWORK), - (180, ErrorCategory.IO), - (182, ErrorCategory.IO), - (219, ErrorCategory.IO), - (220, ErrorCategory.INTERNAL), - (229, ErrorCategory.INTERNAL), - (230, ErrorCategory.COMPONENT), - (231, ErrorCategory.COMPONENT), - (249, ErrorCategory.COMPONENT), - (250, ErrorCategory.VALIDATION), - (259, ErrorCategory.VALIDATION), - (279, ErrorCategory.VALIDATION), - (280, ErrorCategory.IO), - (299, ErrorCategory.IO), - (300, ErrorCategory.COMPONENT), - (319, ErrorCategory.COMPONENT), - # AUTH is only 320–329; security 330–349 → INTERNAL (commons) - (320, ErrorCategory.AUTH), - (329, ErrorCategory.AUTH), - (330, ErrorCategory.INTERNAL), - (349, ErrorCategory.INTERNAL), - (350, ErrorCategory.INTERNAL), - (369, ErrorCategory.INTERNAL), - (370, ErrorCategory.INTERNAL), - (379, ErrorCategory.INTERNAL), - (380, ErrorCategory.INTERNAL), - (389, ErrorCategory.INTERNAL), - (400, ErrorCategory.INTERNAL), - (499, ErrorCategory.INTERNAL), - (500, ErrorCategory.INTERNAL), - (599, ErrorCategory.INTERNAL), - (600, ErrorCategory.INTERNAL), - (699, ErrorCategory.INTERNAL), - (700, ErrorCategory.INTERNAL), - (800, ErrorCategory.INTERNAL), - (804, ErrorCategory.INTERNAL), - (999, ErrorCategory.INTERNAL), - (1000, ErrorCategory.INTERNAL), - (-5, ErrorCategory.INTERNAL), - (-111, ErrorCategory.MODEL), - (-320, ErrorCategory.AUTH), - (-330, ErrorCategory.INTERNAL), + (100, 109, ErrorCategory.CONFIGURATION), + (110, 129, ErrorCategory.MODEL), + (130, 149, ErrorCategory.INTERNAL), + (150, 179, ErrorCategory.NETWORK), + (180, 219, ErrorCategory.IO), + (220, 229, ErrorCategory.INTERNAL), + (230, 249, ErrorCategory.COMPONENT), + (250, 279, ErrorCategory.VALIDATION), + (280, 299, ErrorCategory.IO), + (300, 319, ErrorCategory.COMPONENT), + (320, 349, ErrorCategory.AUTH), + (350, 369, ErrorCategory.IO), + (370, 379, ErrorCategory.VALIDATION), + (380, 389, ErrorCategory.INTERNAL), + (400, 499, ErrorCategory.COMPONENT), + (500, 599, ErrorCategory.CONFIGURATION), + (600, 699, ErrorCategory.COMPONENT), + (700, 999, ErrorCategory.INTERNAL), ], ) -def test_category_for_code(code, expected): - assert category_for_code(code) == expected +def test_category_for_code_matches_canonical_range_boundaries( + first, last, category +): + for code in range(first, last + 1): + assert category_for_code(code) == category + assert category_for_code(-code) == category + + +def test_category_for_code_preserves_zero_and_out_of_range_cases(): + for code in (0, 1, 99, 390, 399, 1000, 12345, 2**31): + assert category_for_code(code) == ErrorCategory.UNSPECIFIED + assert category_for_code(-code) == ErrorCategory.UNSPECIFIED + + +def test_from_proto_preserves_wire_category(): + class ProtoError: + code = ErrorCode.ADAPTER_NOT_SET + category = ErrorCategory.CONFIGURATION + message = "adapter missing" + c_abi_code = -500 + nested_message = "" + param = "" + + def HasField(self, field): + return field in {"c_abi_code", "nested_message", "param"} + + error = SDKException.from_proto(ProtoError()) + assert error.code == ErrorCode.ADAPTER_NOT_SET + assert error.category == ErrorCategory.CONFIGURATION + + +def test_from_proto_unknown_wire_category_is_unspecified(): + class ProtoError: + code = ErrorCode.MODEL_LOAD_FAILED + category = 999 + message = "unknown category" + c_abi_code = -111 + nested_message = "" + param = "" + + def HasField(self, field): + return field in {"c_abi_code", "nested_message", "param"} + + error = SDKException.from_proto(ProtoError()) + assert error.category == ErrorCategory.UNSPECIFIED def test_error_code_covers_idl_surface(): @@ -123,9 +132,7 @@ def test_error_code_covers_idl_surface(): def test_category_defaults_applied_in_ctor(): - # No explicit category -> derived from code via category_for_code. assert SDKException(ErrorCode.MODEL_NOT_FOUND, "x").category == ErrorCategory.MODEL - assert SDKException(ErrorCode.INVALID_INPUT, "x").category == ErrorCategory.VALIDATION # --------------------------------------------------------------------------- # @@ -199,6 +206,10 @@ def test_recovery_suggestions(): assert SDKException(ErrorCode.GENERATION_FAILED, "x").recovery_suggestion is None +def test_storage_error_is_io(): + assert SDKException.storage_error().category == ErrorCategory.IO + + # --------------------------------------------------------------------------- # # is_expected # # --------------------------------------------------------------------------- # diff --git a/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts b/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts index dd3be58303..98048a086e 100644 --- a/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts +++ b/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts @@ -488,7 +488,7 @@ export async function sdkExceptionFromRcResult( ? (absCode as ErrorCodeProto) : ErrorCodeProto.ERROR_CODE_UNKNOWN, `Error code: ${rcResult}`, - { cAbiCode: rcResult } + { cAbiCode: rcResult, category: categoryForCode(rcResult) } ); } diff --git a/bindings/react-native/packages/core/tests/unit/SDKException.test.ts b/bindings/react-native/packages/core/tests/unit/SDKException.test.ts index 14636f9fcc..7c5c02f8ac 100644 --- a/bindings/react-native/packages/core/tests/unit/SDKException.test.ts +++ b/bindings/react-native/packages/core/tests/unit/SDKException.test.ts @@ -14,6 +14,7 @@ import { SDKException, isSDKException, asSDKException, + sdkExceptionFromRcResult, } from '../../src/Foundation/Errors/SDKException'; describe('SDKException', () => { @@ -131,4 +132,12 @@ describe('SDKException', () => { expect(exception.category).toBe(ErrorCategory.ERROR_CATEGORY_AUTH); }); }); + + it('keeps an unmapped raw rac code UNSPECIFIED when the proto code is unknown', async () => { + const exception = await sdkExceptionFromRcResult(-12345); + + expect(exception?.code).toBe(ErrorCode.ERROR_CODE_UNKNOWN); + expect(exception?.cAbiCode).toBe(-12345); + expect(exception?.category).toBe(ErrorCategory.ERROR_CATEGORY_UNSPECIFIED); + }); }); diff --git a/bindings/web/packages/core/tests/unit/Foundation/SDKException.test.ts b/bindings/web/packages/core/tests/unit/Foundation/SDKException.test.ts new file mode 100644 index 0000000000..6d4c79cad5 --- /dev/null +++ b/bindings/web/packages/core/tests/unit/Foundation/SDKException.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { ErrorCategory } from '@runanywhere/proto-ts/errors'; +import { SDKException } from '../../../src/Foundation/SDKException.js'; + +describe('SDKException category fallback', () => { + it('keeps an unmapped raw rac code UNSPECIFIED when proto code is unknown', () => { + const exception = SDKException.fromRACResult(-12345); + + expect(exception.code).toBe(0); + expect(exception.cAbiCode).toBe(-12345); + expect(exception.proto.category).toBe(ErrorCategory.ERROR_CATEGORY_UNSPECIFIED); + }); +}); diff --git a/core/include/rac/core/rac_error_categories.def b/core/include/rac/core/rac_error_categories.def new file mode 100644 index 0000000000..b0080f286f --- /dev/null +++ b/core/include/rac/core/rac_error_categories.def @@ -0,0 +1,21 @@ +// Canonical proto error-category ranges, shared by commons and SDK codegen. +// Define RAC_ERROR_CATEGORY_RANGE(first, last, category) before including. +// Unmapped magnitudes (including zero) are UNSPECIFIED. +RAC_ERROR_CATEGORY_RANGE(100, 109, CONFIGURATION) +RAC_ERROR_CATEGORY_RANGE(110, 129, MODEL) +RAC_ERROR_CATEGORY_RANGE(130, 149, INTERNAL) +RAC_ERROR_CATEGORY_RANGE(150, 179, NETWORK) +RAC_ERROR_CATEGORY_RANGE(180, 219, IO) +RAC_ERROR_CATEGORY_RANGE(220, 229, INTERNAL) +RAC_ERROR_CATEGORY_RANGE(230, 249, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(250, 279, VALIDATION) +RAC_ERROR_CATEGORY_RANGE(280, 299, IO) +RAC_ERROR_CATEGORY_RANGE(300, 319, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(320, 349, AUTH) +RAC_ERROR_CATEGORY_RANGE(350, 369, IO) +RAC_ERROR_CATEGORY_RANGE(370, 379, VALIDATION) +RAC_ERROR_CATEGORY_RANGE(380, 389, INTERNAL) +RAC_ERROR_CATEGORY_RANGE(400, 499, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(500, 599, CONFIGURATION) +RAC_ERROR_CATEGORY_RANGE(600, 699, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(700, 999, INTERNAL) diff --git a/core/include/rac/core/rac_error_proto.h b/core/include/rac/core/rac_error_proto.h index 0625513730..0abcc7e0cf 100644 --- a/core/include/rac/core/rac_error_proto.h +++ b/core/include/rac/core/rac_error_proto.h @@ -19,8 +19,8 @@ * * The implementation lives in `src/core/rac_error_proto.cpp` and uses the * existing internal helpers `rac_error_message()` (in `rac_error.h`) and a - * code-range based ErrorCategory mapping that mirrors - * `event_publisher.cpp::error_category_for_code()`. + * shared `rac::foundation::rac_result_to_proto_category()` mapper, also used + * by the event publisher. * * Memory ownership: on success the caller owns `out_proto->data` and must * release it with `rac_proto_buffer_free()`. On failure `out_proto->data` is @@ -43,17 +43,18 @@ extern "C" { * proto buffer. * * Populates `out_proto` with a serialized `runanywhere.v1.SDKError` containing: - * - `code` — proto ErrorCode = abs(`code`); ERROR_CODE_UNSPECIFIED on - * unrecognised values. - * - `category` — proto ErrorCategory derived from the canonical -100..-899 - * C ABI ranges (matches event_publisher.cpp). + * - `code` — proto ErrorCode = abs(`code`), including unknown values; + * INT32_MIN maps to ERROR_CODE_UNKNOWN because its positive + * magnitude cannot be represented by the int32 proto enum. + * - `category` — derived from the 18 ranges in rac_error_categories.def + * through -999; non-negative and unmapped codes are UNSPECIFIED. * - `message` — `rac_error_message(code)`; never empty for a valid code. * - `c_abi_code` — the original signed `code`, for lossless round-tripping. * - `timestamp_ms`— absent (zero) — populated by event publishers, not here. * - `severity` — ERROR_SEVERITY_ERROR. * - * On `RAC_SUCCESS` input this function writes a default-constructed SDKError - * into the buffer (proto3 default values) and returns `RAC_SUCCESS`. Callers + * On `RAC_SUCCESS` input this function writes an SDKError with code/category + * UNSPECIFIED (and the normal message/severity fields), returning `RAC_SUCCESS`. Callers * that wish to suppress emission of a "success" SDKError should test * `code == RAC_SUCCESS` themselves before calling. * diff --git a/core/include/rac/foundation/rac_proto_adapters.h b/core/include/rac/foundation/rac_proto_adapters.h index 6522371320..cf1356ba41 100644 --- a/core/include/rac/foundation/rac_proto_adapters.h +++ b/core/include/rac/foundation/rac_proto_adapters.h @@ -185,8 +185,8 @@ ::runanywhere::v1::ErrorCategory rac_category_to_proto(rac_error_category_t cate rac_error_category_t rac_proto_to_category(::runanywhere::v1::ErrorCategory category); // Map a rac_result_t error code directly to the proto ErrorCategory bucket -// (canonical -100..-329 ranges). Non-negative codes -> UNSPECIFIED; any other -// negative code -> INTERNAL. Single source of truth for the duplicated mappers +// (the canonical 18 ranges in rac/core/rac_error_categories.def, through -999). +// Non-negative and unmapped codes -> UNSPECIFIED. Single source of truth for the mappers // that previously lived (and drifted on out-of-range codes) in // rac_error_proto.cpp and event_publisher.cpp. ::runanywhere::v1::ErrorCategory rac_result_to_proto_category(rac_result_t code); diff --git a/core/src/core/rac_error_proto.cpp b/core/src/core/rac_error_proto.cpp index d33f3ee0c6..5f4c7b8780 100644 --- a/core/src/core/rac_error_proto.cpp +++ b/core/src/core/rac_error_proto.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include "rac/core/rac_error.h" @@ -36,8 +37,11 @@ void populate_sdk_error(::runanywhere::v1::SDKError* out, rac_result_t code) { } const int32_t signed_code = static_cast(code); - const int32_t abs_code = signed_code < 0 ? -signed_code : signed_code; - out->set_code(static_cast<::runanywhere::v1::ErrorCode>(abs_code)); + const int64_t magnitude = signed_code < 0 ? -static_cast(signed_code) : signed_code; + // INT32_MIN has no positive int32 enum equivalent; retain it in c_abi_code. + out->set_code(magnitude > std::numeric_limits::max() + ? ::runanywhere::v1::ERROR_CODE_UNKNOWN + : static_cast<::runanywhere::v1::ErrorCode>(magnitude)); out->set_category(rac::foundation::rac_result_to_proto_category(code)); const char* message = rac_error_message(code); diff --git a/core/src/foundation/rac_proto_adapters.cpp b/core/src/foundation/rac_proto_adapters.cpp index 420e0ad13f..6636bdf46e 100644 --- a/core/src/foundation/rac_proto_adapters.cpp +++ b/core/src/foundation/rac_proto_adapters.cpp @@ -1143,27 +1143,16 @@ bool rac_model_storage_metrics_from_proto(const ::runanywhere::v1::ModelStorageM // =========================================================================== ::runanywhere::v1::ErrorCategory rac_result_to_proto_category(rac_result_t code) { - // Non-negative codes (success / invalid) carry no error category. if (code >= 0) return ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED; - if (code <= -150 && code >= -179) - return ::runanywhere::v1::ERROR_CATEGORY_NETWORK; - if (code <= -250 && code >= -279) - return ::runanywhere::v1::ERROR_CATEGORY_VALIDATION; - if (code <= -110 && code >= -129) - return ::runanywhere::v1::ERROR_CATEGORY_MODEL; - if ((code <= -180 && code >= -219) || (code <= -280 && code >= -299)) - return ::runanywhere::v1::ERROR_CATEGORY_IO; - if (code <= -320 && code >= -329) - return ::runanywhere::v1::ERROR_CATEGORY_AUTH; - if (code <= -100 && code >= -109) - return ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION; - if ((code <= -230 && code >= -249) || (code <= -300 && code >= -319)) - return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; - // Any other negative code is an unmapped error -> INTERNAL (canonical - // fallback; rac_error_proto.cpp previously returned UNSPECIFIED here, the - // drift this consolidation fixes). - return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; + + const int64_t magnitude = -static_cast(code); +#define RAC_ERROR_CATEGORY_RANGE(first, last, category) \ + if (magnitude >= first && magnitude <= last) \ + return ::runanywhere::v1::ERROR_CATEGORY_##category; +#include "rac/core/rac_error_categories.def" +#undef RAC_ERROR_CATEGORY_RANGE + return ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED; } ::runanywhere::v1::ErrorCategory rac_category_to_proto(rac_error_category_t category) { diff --git a/core/tests/test_result_to_proto_error.cpp b/core/tests/test_result_to_proto_error.cpp index 670b0d3a83..a3ac72efbc 100644 --- a/core/tests/test_result_to_proto_error.cpp +++ b/core/tests/test_result_to_proto_error.cpp @@ -20,6 +20,7 @@ #include "rac/core/rac_error.h" #include "rac/core/rac_error_proto.h" +#include "rac/foundation/rac_proto_adapters.h" #include "rac/foundation/rac_proto_buffer.h" #if defined(RAC_HAVE_PROTOBUF) @@ -267,6 +268,73 @@ void test_null_buffer_returns_invalid_argument() { CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "null buffer rejected"); } +void test_canonical_category_ranges() { + struct Case { + rac_result_t code; + ::runanywhere::v1::ErrorCategory category; + }; + const Case cases[] = { + {static_cast(-100), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-109), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-110), ::runanywhere::v1::ERROR_CATEGORY_MODEL}, + {static_cast(-129), ::runanywhere::v1::ERROR_CATEGORY_MODEL}, + {static_cast(-130), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-149), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-150), ::runanywhere::v1::ERROR_CATEGORY_NETWORK}, + {static_cast(-179), ::runanywhere::v1::ERROR_CATEGORY_NETWORK}, + {static_cast(-180), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-219), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-220), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-229), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-230), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-249), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-250), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-279), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-280), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-299), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-300), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-319), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-320), ::runanywhere::v1::ERROR_CATEGORY_AUTH}, + {static_cast(-349), ::runanywhere::v1::ERROR_CATEGORY_AUTH}, + {static_cast(-350), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-369), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-370), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-379), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-380), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-389), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-400), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-499), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-500), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-599), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-600), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-699), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-700), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-999), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + }; + for (const auto& test_case : cases) { + CHECK(rac::foundation::rac_result_to_proto_category(test_case.code) == test_case.category, + "canonical negative range boundary"); + CHECK(rac::foundation::rac_result_to_proto_category(-test_case.code) == + ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, + "positive result is not an error"); + rac_proto_buffer_t buffer; + rac_proto_buffer_init(&buffer); + CHECK(rac_result_to_proto_error(test_case.code, &buffer) == RAC_SUCCESS, + "range boundary serializes"); + ::runanywhere::v1::SDKError parsed; + CHECK(parsed.ParseFromArray(buffer.data, static_cast(buffer.size)), + "range boundary proto parses"); + CHECK(parsed.category() == test_case.category, "serialized category matches mapper"); + CHECK(parsed.c_abi_code() == test_case.code, "serialized ABI code preserved"); + rac_proto_buffer_free(&buffer); + } + for (const rac_result_t code : {0, -1, -99, -390, -399, -1000, -12345, INT32_MIN}) { + CHECK(rac::foundation::rac_result_to_proto_category(code) == + ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, + "unmapped and minimum integer codes remain unspecified"); + } +} + void test_success_code_writes_default_proto() { rac_proto_buffer_t buffer; rac_proto_buffer_init(&buffer); @@ -291,20 +359,24 @@ void test_unknown_negative_code_is_handled() { // A code outside any known range still serializes; category falls back to // UNSPECIFIED, which IS allowed for genuinely unknown codes (not in the // canonical -100..-999 ranges). - rac_proto_buffer_t buffer; - rac_proto_buffer_init(&buffer); - - const rac_result_t kBogus = static_cast(-12345); - const rac_result_t rc = rac_result_to_proto_error(kBogus, &buffer); - CHECK(rc == RAC_SUCCESS, "bogus code still serializes"); - - ::runanywhere::v1::SDKError parsed; - const bool parsed_ok = parsed.ParseFromArray(buffer.data, static_cast(buffer.size)); - CHECK(parsed_ok, "bogus code parses"); - CHECK(parsed.c_abi_code() == -12345, "bogus c_abi_code preserved"); - CHECK(!parsed.message().empty(), "bogus code message not empty"); - - rac_proto_buffer_free(&buffer); + for (const rac_result_t code : {-12345, INT32_MIN}) { + rac_proto_buffer_t buffer; + rac_proto_buffer_init(&buffer); + const rac_result_t rc = rac_result_to_proto_error(code, &buffer); + CHECK(rc == RAC_SUCCESS, "unknown code still serializes"); + ::runanywhere::v1::SDKError parsed; + CHECK(parsed.ParseFromArray(buffer.data, static_cast(buffer.size)), + "unknown code parses"); + CHECK(parsed.c_abi_code() == code, "unknown ABI code preserved"); + CHECK(parsed.category() == ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, + "unknown category is unspecified"); + if (code == INT32_MIN) { + CHECK(parsed.code() == ::runanywhere::v1::ERROR_CODE_UNKNOWN, + "unrepresentable magnitude maps to UNKNOWN"); + } + CHECK(!parsed.message().empty(), "unknown code message not empty"); + rac_proto_buffer_free(&buffer); + } } #endif // RAC_HAVE_PROTOBUF @@ -315,6 +387,8 @@ int main() { #if defined(RAC_HAVE_PROTOBUF) std::printf("[ RUN ] test_all_canonical_codes\n"); test_all_canonical_codes(); + std::printf("[ RUN ] test_canonical_category_ranges\n"); + test_canonical_category_ranges(); std::printf("[ RUN ] test_null_buffer_returns_invalid_argument\n"); test_null_buffer_returns_invalid_argument(); std::printf("[ RUN ] test_success_code_writes_default_proto\n"); diff --git a/idl/codegen/generate_python_errors.py b/idl/codegen/generate_python_errors.py index abb53b26e9..e89dd1c416 100755 --- a/idl/codegen/generate_python_errors.py +++ b/idl/codegen/generate_python_errors.py @@ -10,8 +10,8 @@ # reader to "keep in sync" — the kind of instruction nothing enforces. # # This generates both enums instead. errors.py keeps its SDKException, its -# category_for_code range table, and everything else that is real logic; only -# the transcription moves here. +# helpers and factories. Category ranges come from the same manifest commons +# compiles, so Python never maintains a second table. # # Output: # bindings/python/runanywhere/_generated_errors.py @@ -22,6 +22,7 @@ from __future__ import annotations +import re import sys from pathlib import Path @@ -82,7 +83,34 @@ def collect( return found +def category_ranges() -> list[tuple[int, int, str]]: + """Read the same declarative category ranges compiled by commons.""" + manifest = Path(__file__).resolve().parents[2] / "core/include/rac/core/rac_error_categories.def" + ranges: list[tuple[int, int, str]] = [] + for line in manifest.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("//"): + continue + macro = line.split("//", 1)[0].strip() + if not macro: + continue + match = re.fullmatch( + r"RAC_ERROR_CATEGORY_RANGE\(\s*(\d+)\s*,\s*(\d+)\s*,\s*([A-Z]+)\s*\)", + macro, + ) + if match is None: + raise ValueError(f"Invalid error-category range: {line}") + first, last, category = match.groups() + start, end = int(first), int(last) + if start <= 0 or start > end or (ranges and start <= ranges[-1][1]): + raise ValueError(f"Unordered or overlapping error-category range: {line}") + ranges.append((start, end, category)) + if not ranges: + raise ValueError("Error-category manifest is empty") + return ranges + + def render(enums: dict[str, list[tuple[str, int, str]]]) -> str: + """Emit error enums and the category helper from shared canonical inputs.""" lines: list[str] = [] # One-line docstring then the future import, per the Python SDK's module # convention; everything else is a comment below them. @@ -92,6 +120,7 @@ def render(enums: dict[str, list[tuple[str, int, str]]]) -> str: lines.append("") lines.append("# GENERATED FILE - DO NOT EDIT.") lines.append("# Regenerate with: idl/codegen/generate_python_errors.py") + lines.append("# Category ranges: core/include/rac/core/rac_error_categories.def") lines.append("#") lines.append("# Values are the positive canonical numbers from the IDL. The C ABI returns") lines.append("# them negated as rac_result_t; negate once at the boundary rather than") @@ -115,8 +144,25 @@ def render(enums: dict[str, list[tuple[str, int, str]]]) -> str: lines.append(f" {py_name} = {number}") lines.append("") + lines.extend([ + "", + "def category_for_code(code: int) -> ErrorCategory:", + ' """Map code magnitudes using the canonical commons category ranges."""', + " magnitude = abs(int(code))", + ]) + categories = {name for name, _, _ in enums["ErrorCategory"]} + for first, last, category in category_ranges(): + if category not in categories: + raise ValueError(f"Unknown error category in manifest: {category}") + lines.extend([ + f" if {first} <= magnitude <= {last}:", + f" return ErrorCategory.{category}", + ]) + lines.extend([" return ErrorCategory.UNSPECIFIED", ""]) + # Sorted so RUF022 stays quiet on every regeneration. - names = ", ".join(f'"{n}"' for n in sorted(n for n in WANTED if enums.get(n))) + exported = sorted([n for n in WANTED if enums.get(n)] + ["category_for_code"]) + names = ", ".join(f'"{n}"' for n in exported) lines.append("") lines.append(f"__all__ = [{names}]") lines.append("")