From e525642127fb694d4c8be8acb31037543684eae4 Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Mon, 13 Apr 2026 11:54:39 +0100 Subject: [PATCH 1/4] fix: Surface meaningful error messages for connection and timeout failures ConnectionError and TimeoutError from @elastic/transport now produce distinct error codes (connection_error, timeout_error) with descriptive messages including the target URL when available, instead of the generic transport_error with an empty message. Closes #99 --- src/es/handler.ts | 18 +++++++- test/es/handler.test.ts | 97 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/src/es/handler.ts b/src/es/handler.ts index d6972466..fa0c1663 100644 --- a/src/es/handler.ts +++ b/src/es/handler.ts @@ -82,7 +82,7 @@ function missingConfigError (err: unknown): JsonValue { return { error: { code: 'missing_config', message } } } -/** builds a `transport_error` payload from a thrown transport error */ +/** builds a structured error payload from a thrown transport error */ function transportError (err: unknown): JsonValue { if (err instanceof errors.ResponseError) { return { @@ -94,6 +94,22 @@ function transportError (err: unknown): JsonValue { } } + if (err instanceof errors.ConnectionError) { + return { error: { code: 'connection_error', message: connectionMessage(err) } } + } + + if (err instanceof errors.TimeoutError) { + const message = err.message || 'request timed out' + return { error: { code: 'timeout_error', message } } + } + const message = err instanceof Error ? err.message : String(err) return { error: { code: 'transport_error', message } } } + +function connectionMessage (err: errors.ConnectionError): string { + const reason = err.message || 'connection failed' + // err.meta is DiagnosticResult; .meta.connection is the nested transport metadata + const url = err.meta?.meta?.connection?.url?.toString() + return url ? `${reason} (${url})` : reason +} diff --git a/test/es/handler.test.ts b/test/es/handler.test.ts index c50f71c0..a2be2e06 100644 --- a/test/es/handler.test.ts +++ b/test/es/handler.test.ts @@ -56,6 +56,20 @@ function spy unknown>(fn: T): T & { calls: Param return wrapper } +function makeDiagnostic (url: string) { + return { + body: null, + statusCode: null, + headers: {}, + warnings: null, + meta: { + context: null, name: 'test', attempts: 1, aborted: false, + request: { params: { method: 'GET', path: '/' }, options: {}, id: 1 }, + connection: { url: new URL(url) }, + }, + } +} + describe('createEsHandler', () => { it('calls buildRequestParams with the definition and parsed input', async () => { const def = makeDef() @@ -161,7 +175,7 @@ describe('createEsHandler', () => { assert.deepEqual(err['body'], esErrorBody) }) - it('returns transport_error with message for non-ResponseError transport errors', async () => { + it('returns connection_error with message for ConnectionError with non-empty message', async () => { const deps = makeDeps({ getTransport: () => ({ request: async () => { throw new errors.ConnectionError('Connection refused') }, @@ -171,9 +185,86 @@ describe('createEsHandler', () => { const handler = createEsHandler(makeDef(), [], deps) const result = await handler(parsedInput()) as Record + const err = result['error'] as Record + assert.equal(err['code'], 'connection_error') + assert.equal(err['message'], 'Connection refused') + }) + + it('returns connection_error with URL from meta when message is empty', async () => { + const connErr = new errors.ConnectionError('', makeDiagnostic('http://localhost:19999') as never) + const deps = makeDeps({ + getTransport: () => ({ + request: async () => { throw connErr }, + } as unknown as Transport), + }) + + const handler = createEsHandler(makeDef(), [], deps) + const result = await handler(parsedInput()) as Record + + const err = result['error'] as Record + assert.equal(err['code'], 'connection_error') + assert.equal(err['message'], 'connection failed (http://localhost:19999/)') + }) + + it('includes both message and URL when ConnectionError has both', async () => { + const connErr = new errors.ConnectionError('Connection refused', makeDiagnostic('http://localhost:19999') as never) + const deps = makeDeps({ + getTransport: () => ({ + request: async () => { throw connErr }, + } as unknown as Transport), + }) + + const handler = createEsHandler(makeDef(), [], deps) + const result = await handler(parsedInput()) as Record + + const err = result['error'] as Record + assert.equal(err['code'], 'connection_error') + assert.equal(err['message'], 'Connection refused (http://localhost:19999/)') + }) + + it('returns connection_error with fallback message when both message and cause are empty', async () => { + const connErr = new errors.ConnectionError('') + const deps = makeDeps({ + getTransport: () => ({ + request: async () => { throw connErr }, + } as unknown as Transport), + }) + + const handler = createEsHandler(makeDef(), [], deps) + const result = await handler(parsedInput()) as Record + + const err = result['error'] as Record + assert.equal(err['code'], 'connection_error') + assert.ok((err['message'] as string).length > 0, 'message should not be empty') + }) + + it('returns timeout_error for TimeoutError', async () => { + const deps = makeDeps({ + getTransport: () => ({ + request: async () => { throw new errors.TimeoutError('Request timed out') }, + } as unknown as Transport), + }) + + const handler = createEsHandler(makeDef(), [], deps) + const result = await handler(parsedInput()) as Record + + const err = result['error'] as Record + assert.equal(err['code'], 'timeout_error') + assert.equal(err['message'], 'Request timed out') + }) + + it('returns transport_error with message for non-ResponseError non-ConnectionError errors', async () => { + const deps = makeDeps({ + getTransport: () => ({ + request: async () => { throw new Error('something unexpected') }, + } as unknown as Transport), + }) + + const handler = createEsHandler(makeDef(), [], deps) + const result = await handler(parsedInput()) as Record + const err = result['error'] as Record assert.equal(err['code'], 'transport_error') - assert.ok(typeof err['message'] === 'string') - assert.equal(err['status_code'], undefined) + assert.equal(err['message'], 'something unexpected') }) }) From 1e1d925cb4ce4763a3576e6e2b7a3c5d19d96a8d Mon Sep 17 00:00:00 2001 From: Josh Mock Date: Mon, 13 Apr 2026 11:37:12 -0500 Subject: [PATCH 2/4] Update src/es/handler.ts --- src/es/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/es/handler.ts b/src/es/handler.ts index fa0c1663..a3afff0e 100644 --- a/src/es/handler.ts +++ b/src/es/handler.ts @@ -99,7 +99,7 @@ function transportError (err: unknown): JsonValue { } if (err instanceof errors.TimeoutError) { - const message = err.message || 'request timed out' + const message = err.message ?? 'request timed out' return { error: { code: 'timeout_error', message } } } From 86899e3e57b118751c8fb4ec14454168adc7a48f Mon Sep 17 00:00:00 2001 From: Josh Mock Date: Mon, 13 Apr 2026 11:37:21 -0500 Subject: [PATCH 3/4] Update src/es/handler.ts --- src/es/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/es/handler.ts b/src/es/handler.ts index a3afff0e..54f4ab6a 100644 --- a/src/es/handler.ts +++ b/src/es/handler.ts @@ -108,7 +108,7 @@ function transportError (err: unknown): JsonValue { } function connectionMessage (err: errors.ConnectionError): string { - const reason = err.message || 'connection failed' + const reason = err.message ?? 'connection failed' // err.meta is DiagnosticResult; .meta.connection is the nested transport metadata const url = err.meta?.meta?.connection?.url?.toString() return url ? `${reason} (${url})` : reason From 94f046365129777349974ae4add32a61217cca62 Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Mon, 13 Apr 2026 17:54:20 +0100 Subject: [PATCH 4/4] fix: Move ConnectionError/TimeoutError handling into extracted errors module Adapts to the refactor that extracted error helpers into src/es/errors.ts. Removes stale local copies from handler.ts and moves the ConnectionError and TimeoutError branches into the shared module. Reverts `??` back to `||` for message fallbacks: empty string is the bug scenario (elastic/elastic-transport-js#366), so we need falsy checks, not just nullish checks. --- src/es/errors.ts | 18 +++++++++++++++++- src/es/handler.ts | 38 -------------------------------------- 2 files changed, 17 insertions(+), 39 deletions(-) diff --git a/src/es/errors.ts b/src/es/errors.ts index a0f90c79..f5d9e96e 100644 --- a/src/es/errors.ts +++ b/src/es/errors.ts @@ -12,7 +12,7 @@ export function missingConfigError (err: unknown): JsonValue { return { error: { code: 'missing_config', message } } } -/** Builds a `transport_error` payload from a thrown transport error. */ +/** Builds a structured error payload from a thrown transport error. */ export function transportError (err: unknown): JsonValue { if (err instanceof errors.ResponseError) { return { @@ -24,6 +24,22 @@ export function transportError (err: unknown): JsonValue { } } + if (err instanceof errors.ConnectionError) { + return { error: { code: 'connection_error', message: connectionMessage(err) } } + } + + if (err instanceof errors.TimeoutError) { + const message = err.message || 'request timed out' + return { error: { code: 'timeout_error', message } } + } + const message = err instanceof Error ? err.message : String(err) return { error: { code: 'transport_error', message } } } + +function connectionMessage (err: errors.ConnectionError): string { + const reason = err.message || 'connection failed' + // err.meta is DiagnosticResult; .meta.connection is the nested transport metadata + const url = err.meta?.meta?.connection?.url?.toString() + return url ? `${reason} (${url})` : reason +} diff --git a/src/es/handler.ts b/src/es/handler.ts index a5cb9330..8f728f5a 100644 --- a/src/es/handler.ts +++ b/src/es/handler.ts @@ -75,41 +75,3 @@ export function createEsHandler ( } } } - -/** builds a `missing_config` error payload from a thrown error */ -function missingConfigError (err: unknown): JsonValue { - const message = err instanceof Error ? err.message : String(err) - return { error: { code: 'missing_config', message } } -} - -/** builds a structured error payload from a thrown transport error */ -function transportError (err: unknown): JsonValue { - if (err instanceof errors.ResponseError) { - return { - error: { - code: 'transport_error', - status_code: err.statusCode ?? null, - body: err.body as JsonValue ?? null - } - } - } - - if (err instanceof errors.ConnectionError) { - return { error: { code: 'connection_error', message: connectionMessage(err) } } - } - - if (err instanceof errors.TimeoutError) { - const message = err.message ?? 'request timed out' - return { error: { code: 'timeout_error', message } } - } - - const message = err instanceof Error ? err.message : String(err) - return { error: { code: 'transport_error', message } } -} - -function connectionMessage (err: errors.ConnectionError): string { - const reason = err.message ?? 'connection failed' - // err.meta is DiagnosticResult; .meta.connection is the nested transport metadata - const url = err.meta?.meta?.connection?.url?.toString() - return url ? `${reason} (${url})` : reason -}