From 13e0490cdf0cd334dc7cbabee0a0bc53b9b31af9 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Wed, 7 Jan 2026 09:38:27 +0200 Subject: [PATCH] Unify error code in LibsqlError for local and remote We currently have divergence in what error code means in local and remote setups. Fix the consistency issue by making error code mean the basic one, which is what the SQL over HTTP protocol returns. However, add SQLite extended error codes to LibSqlError and populate that for the local setup. We'll add that for remote too, but that requires a SQL over HTTP protocol extension and server side change. --- .github/workflows/ci.yaml | 44 ++++++-------- .../src/__tests__/client.test.ts | 58 +++++++++++++++++++ packages/libsql-client/src/hrana.ts | 5 +- packages/libsql-client/src/sqlite3.ts | 50 +++++++++++++++- packages/libsql-core/src/api.ts | 7 ++- packages/libsql-core/src/uri.ts | 1 + testing/hrana-test-server/server_v2.py | 5 +- testing/hrana-test-server/server_v3.py | 5 +- 8 files changed, 143 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9bad04a4..c0e82445 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -114,35 +114,30 @@ jobs: working-directory: ./packages/libsql-core - name: "Install npm dependencies" run: "npm ci" - - name: "Checkout hrana-test-server" - uses: actions/checkout@v4 - with: - repository: "libsql/hrana-test-server" - path: "packages/libsql-client/hrana-test-server" - name: "Setup Python" uses: actions/setup-python@v4 with: python-version: "3.10" - name: "Install pip dependencies" - run: "pip install -r hrana-test-server/requirements.txt" + run: "pip install -r ../../testing/hrana-test-server/requirements.txt" - name: "Build" run: "npm run build" - name: "Test Hrana 1 over WebSocket" - run: "python hrana-test-server/server_v1.py npm test" + run: "python ../../testing/hrana-test-server/server_v1.py npm test" env: { "URL": "ws://localhost:8080", "SERVER": "test_v1" } - name: "Test Hrana 2 over WebSocket" - run: "python hrana-test-server/server_v2.py npm test" + run: "python ../../testing/hrana-test-server/server_v2.py npm test" env: { "URL": "ws://localhost:8080", "SERVER": "test_v2" } - name: "Test Hrana 2 over HTTP" - run: "python hrana-test-server/server_v2.py npm test" + run: "python ../../testing/hrana-test-server/server_v2.py npm test" env: { "URL": "http://localhost:8080", "SERVER": "test_v2" } # - name: "Test Hrana 3 over WebSocket" - # run: "python hrana-test-server/server_v3.py npm test" + # run: "python ../../testing/hrana-test-server/server_v3.py npm test" # env: {"URL": "ws://localhost:8080", "SERVER": "test_v3"} # - name: "Test Hrana 3 over HTTP" - # run: "python hrana-test-server/server_v3.py npm test" + # run: "python ../../testing/hrana-test-server/server_v3.py npm test" # env: {"URL": "http://localhost:8080", "SERVER": "test_v3"} - name: "Test local file" run: "npm test" @@ -176,17 +171,12 @@ jobs: - name: "Install npm dependencies" run: "npm ci" - - name: "Checkout hrana-test-server" - uses: actions/checkout@v4 - with: - repository: "libsql/hrana-test-server" - path: "packages/libsql-client/hrana-test-server" - name: "Setup Python" uses: actions/setup-python@v4 with: python-version: "3.10" - name: "Install pip dependencies" - run: "pip install -r hrana-test-server/requirements.txt" + run: "pip install -r ../../testing/hrana-test-server/requirements.txt" - name: "Build" run: "npm run build" @@ -194,35 +184,35 @@ jobs: run: "cd smoke_test/workers && npm link ../.." - name: "Local test with Hrana 1 over WebSocket" - run: "cd smoke_test/workers && python ../../hrana-test-server/server_v1.py node --dns-result-order=ipv4first test.js" + run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v1.py node --dns-result-order=ipv4first test.js" env: { "LOCAL": "1", "URL": "ws://localhost:8080" } - name: "Local test with Hrana 2 over WebSocket" - run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" + run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" env: { "LOCAL": "1", "URL": "ws://localhost:8080" } - name: "Local test with Hrana 2 over HTTP" - run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" + run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" env: { "LOCAL": "1", "URL": "http://localhost:8080" } # - name: "Local test with Hrana 3 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" # env: {"LOCAL": "1", "URL": "ws://localhost:8080"} # - name: "Local test with Hrana 3 over HTTP" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" # env: {"LOCAL": "1", "URL": "http://localhost:8080"} # - name: "Non-local test with Hrana 1 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v1.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v1.py node test.js" # env: {"LOCAL": "0", "URL": "ws://localhost:8080"} # - name: "Non-local test with Hrana 2 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node test.js" # env: {"LOCAL": "0", "URL": "ws://localhost:8080"} # - name: "Non-local test with Hrana 2 over HTTP" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node test.js" # env: {"LOCAL": "0", "URL": "http://localhost:8080"} # - name: "Non-local test with Hrana 3 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node test.js" # env: {"LOCAL": "0", "URL": "ws://localhost:8080"} # - name: "Non-local test with Hrana 3 over HTTP" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node test.js" # env: {"LOCAL": "0", "URL": "http://localhost:8080"} # "vercel-test": diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index bbf2ec86..e63b1ec3 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -1606,6 +1606,64 @@ describe("transaction()", () => { } }); +// Test to verify constraint error codes +// - code: base error code (e.g., SQLITE_CONSTRAINT) - consistent across local and remote +// - extendedCode: extended error code (e.g., SQLITE_CONSTRAINT_PRIMARYKEY) - available when supported +(server !== "test_v1" ? describe : describe.skip)( + "constraint error codes", + () => { + test( + "PRIMARY KEY constraint violation", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t_pk_test"); + await c.execute( + "CREATE TABLE t_pk_test (id INTEGER PRIMARY KEY, name TEXT)", + ); + await c.execute("INSERT INTO t_pk_test VALUES (1, 'first')"); + + try { + await c.execute( + "INSERT INTO t_pk_test VALUES (1, 'duplicate')", + ); + throw new Error("Expected PRIMARY KEY constraint error"); + } catch (e: any) { + expect(e.code).toBe("SQLITE_CONSTRAINT"); + if (e.extendedCode !== undefined) { + expect(e.extendedCode).toBe( + "SQLITE_CONSTRAINT_PRIMARYKEY", + ); + } + } + }), + ); + + test( + "UNIQUE constraint violation", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t_unique_test"); + await c.execute( + "CREATE TABLE t_unique_test (id INTEGER, name TEXT UNIQUE)", + ); + await c.execute( + "INSERT INTO t_unique_test VALUES (1, 'unique_name')", + ); + + try { + await c.execute( + "INSERT INTO t_unique_test VALUES (2, 'unique_name')", + ); + throw new Error("Expected UNIQUE constraint error"); + } catch (e: any) { + expect(e.code).toBe("SQLITE_CONSTRAINT"); + if (e.extendedCode !== undefined) { + expect(e.extendedCode).toBe("SQLITE_CONSTRAINT_UNIQUE"); + } + } + }), + ); + }, +); + (isSqld ? test : test.skip)("embedded replica test", async () => { const remote = createClient(config); const embedded = createClient({ diff --git a/packages/libsql-client/src/hrana.ts b/packages/libsql-client/src/hrana.ts index d97b44f2..588796b4 100644 --- a/packages/libsql-client/src/hrana.ts +++ b/packages/libsql-client/src/hrana.ts @@ -157,6 +157,7 @@ export abstract class HranaTransaction implements Transaction { mappedError.message, i, mappedError.code, + mappedError.extendedCode, mappedError.rawCode, mappedError.cause instanceof Error ? mappedError.cause @@ -338,6 +339,7 @@ export async function executeHranaBatch( mappedError.message, i, mappedError.code, + mappedError.extendedCode, mappedError.rawCode, mappedError.cause instanceof Error ? mappedError.cause @@ -400,7 +402,8 @@ export function resultSetFromHrana(hranaRows: hrana.RowsResult): ResultSet { export function mapHranaError(e: unknown): unknown { if (e instanceof hrana.ClientError) { const code = mapHranaErrorCode(e); - return new LibsqlError(e.message, code, undefined, e); + // TODO: Parse extendedCode once the SQL over HTTP protocol supports it + return new LibsqlError(e.message, code, undefined, undefined, e); } return e; } diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index a12895f4..b8a9ad87 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -175,6 +175,7 @@ export class Sqlite3Client implements Client { e.message, i, e.code, + e.extendedCode, e.rawCode, e.cause instanceof Error ? e.cause : undefined, ); @@ -217,6 +218,7 @@ export class Sqlite3Client implements Client { e.message, i, e.code, + e.extendedCode, e.rawCode, e.cause instanceof Error ? e.cause : undefined, ); @@ -351,6 +353,7 @@ export class Sqlite3Transaction implements Transaction { e.message, i, e.code, + e.extendedCode, e.rawCode, e.cause instanceof Error ? e.cause : undefined, ); @@ -568,7 +571,52 @@ function executeMultiple(db: Database.Database, sql: string): void { function mapSqliteError(e: unknown): unknown { if (e instanceof Database.SqliteError) { - return new LibsqlError(e.message, e.code, e.rawCode, e); + const extendedCode = e.code; + const code = mapToBaseCode(e.rawCode); + return new LibsqlError(e.message, code, extendedCode, e.rawCode, e); } return e; } + +// Map SQLite raw error code to base error code string. +// Extended error codes are (base | (extended << 8)), so base = rawCode & 0xFF +function mapToBaseCode(rawCode: number | undefined): string { + if (rawCode === undefined) { + return "SQLITE_UNKNOWN"; + } + const baseCode = rawCode & 0xff; + return ( + sqliteErrorCodes[baseCode] ?? `SQLITE_UNKNOWN_${baseCode.toString()}` + ); +} + +const sqliteErrorCodes: Record = { + 1: "SQLITE_ERROR", + 2: "SQLITE_INTERNAL", + 3: "SQLITE_PERM", + 4: "SQLITE_ABORT", + 5: "SQLITE_BUSY", + 6: "SQLITE_LOCKED", + 7: "SQLITE_NOMEM", + 8: "SQLITE_READONLY", + 9: "SQLITE_INTERRUPT", + 10: "SQLITE_IOERR", + 11: "SQLITE_CORRUPT", + 12: "SQLITE_NOTFOUND", + 13: "SQLITE_FULL", + 14: "SQLITE_CANTOPEN", + 15: "SQLITE_PROTOCOL", + 16: "SQLITE_EMPTY", + 17: "SQLITE_SCHEMA", + 18: "SQLITE_TOOBIG", + 19: "SQLITE_CONSTRAINT", + 20: "SQLITE_MISMATCH", + 21: "SQLITE_MISUSE", + 22: "SQLITE_NOLFS", + 23: "SQLITE_AUTH", + 24: "SQLITE_FORMAT", + 25: "SQLITE_RANGE", + 26: "SQLITE_NOTADB", + 27: "SQLITE_NOTICE", + 28: "SQLITE_WARNING", +}; diff --git a/packages/libsql-core/src/api.ts b/packages/libsql-core/src/api.ts index e22ec94c..471eb22b 100644 --- a/packages/libsql-core/src/api.ts +++ b/packages/libsql-core/src/api.ts @@ -489,12 +489,15 @@ export type InArgs = Array | Record; export class LibsqlError extends Error { /** Machine-readable error code. */ code: string; + /** Extended error code with more specific information (e.g., SQLITE_CONSTRAINT_PRIMARYKEY). */ + extendedCode?: string; /** Raw numeric error code */ rawCode?: number; constructor( message: string, code: string, + extendedCode?: string, rawCode?: number, cause?: Error, ) { @@ -503,6 +506,7 @@ export class LibsqlError extends Error { } super(message, { cause }); this.code = code; + this.extendedCode = extendedCode; this.rawCode = rawCode; this.name = "LibsqlError"; } @@ -517,10 +521,11 @@ export class LibsqlBatchError extends LibsqlError { message: string, statementIndex: number, code: string, + extendedCode?: string, rawCode?: number, cause?: Error, ) { - super(message, code, rawCode, cause); + super(message, code, extendedCode, rawCode, cause); this.statementIndex = statementIndex; this.name = "LibsqlBatchError"; } diff --git a/packages/libsql-core/src/uri.ts b/packages/libsql-core/src/uri.ts index 9642d561..470df4ef 100644 --- a/packages/libsql-core/src/uri.ts +++ b/packages/libsql-core/src/uri.ts @@ -145,6 +145,7 @@ function percentDecode(text: string): string { `URL component has invalid percent encoding: ${e}`, "URL_INVALID", undefined, + undefined, e, ); } diff --git a/testing/hrana-test-server/server_v2.py b/testing/hrana-test-server/server_v2.py index b7024a3b..66a98527 100644 --- a/testing/hrana-test-server/server_v2.py +++ b/testing/hrana-test-server/server_v2.py @@ -12,6 +12,7 @@ import aiohttp.web import c3 +from sqlite3_error_map import sqlite_error_code_to_name logger = logging.getLogger("server") persistent_db_file = os.getenv("PERSISTENT_DB") @@ -521,7 +522,9 @@ class ResponseError(RuntimeError): def __init__(self, message, code=None): if isinstance(message, c3.SqliteError): if code is None: - code = message.error_name + # Use base error code (error_code & 0xFF) instead of extended code + base_code = message.error_code & 0xFF if message.error_code else None + code = sqlite_error_code_to_name.get(base_code) message = str(message) super().__init__(message) self.code = code diff --git a/testing/hrana-test-server/server_v3.py b/testing/hrana-test-server/server_v3.py index 93a4ff5f..056b8543 100644 --- a/testing/hrana-test-server/server_v3.py +++ b/testing/hrana-test-server/server_v3.py @@ -14,6 +14,7 @@ import c3 import from_proto import to_proto +from sqlite3_error_map import sqlite_error_code_to_name import proto.hrana.http_pb2 import proto.hrana.ws_pb2 @@ -649,7 +650,9 @@ class ResponseError(RuntimeError): def __init__(self, message, code=None): if isinstance(message, c3.SqliteError): if code is None: - code = message.error_name + # Use base error code (error_code & 0xFF) instead of extended code + base_code = message.error_code & 0xFF if message.error_code else None + code = sqlite_error_code_to_name.get(base_code) message = str(message) super().__init__(message) self.code = code