diff --git a/src/admin/mockttp-admin-model.ts b/src/admin/mockttp-admin-model.ts index 4fa705495..3e2f3f9a1 100644 --- a/src/admin/mockttp-admin-model.ts +++ b/src/admin/mockttp-admin-model.ts @@ -25,6 +25,7 @@ const RESPONSE_COMPLETED_TOPIC = 'response-completed'; const REQUEST_ABORTED_TOPIC = 'request-aborted'; const TLS_CLIENT_ERROR_TOPIC = 'tls-client-error'; const CLIENT_ERROR_TOPIC = 'client-error'; +const REQUEST_HANDLER_ERROR_TOPIC = 'request-handler-error'; async function buildMockedEndpointData(endpoint: ServerMockedEndpoint): Promise { return { @@ -82,6 +83,12 @@ export function buildAdminServerModel( }) }); + mockServer.on('request-handler-error', (error) => { + pubsub.publish(REQUEST_HANDLER_ERROR_TOPIC, { + requestHandlerError: error + }) + }); + return { Query: { mockedEndpoints: async (): Promise => { @@ -181,4 +188,4 @@ export function buildAdminServerModel( } } }; -} \ No newline at end of file +} diff --git a/src/admin/mockttp-schema.ts b/src/admin/mockttp-schema.ts index d288c4395..f729da003 100644 --- a/src/admin/mockttp-schema.ts +++ b/src/admin/mockttp-schema.ts @@ -81,6 +81,14 @@ export const MockttpSchema = gql` remotePort: Int! } + type RequestHandlerError { + code: String + cmd: String + signal: String + statusCode: Int + statusMessage: String + } + type InitiatedRequest { id: ID! timingEvents: Json! @@ -133,4 +141,4 @@ export const MockttpSchema = gql` rawHeaders: Json! body: Buffer! } -`; \ No newline at end of file +`; diff --git a/src/client/mockttp-admin-request-builder.ts b/src/client/mockttp-admin-request-builder.ts index a79fcbf06..3a4b1f443 100644 --- a/src/client/mockttp-admin-request-builder.ts +++ b/src/client/mockttp-admin-request-builder.ts @@ -303,6 +303,15 @@ export class MockttpAdminRequestBuilder { body } } + }`, + 'request-handler-error': gql`subscription OnRequestHandlerError { + requestHandlerError { + code + cmd + signal + statusCode + statusMessage + } }` }[event]; @@ -367,4 +376,4 @@ export class MockttpAdminRequestBuilder { return mockedEndpoint; } -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index c90e95f0e..781f5c16a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -142,4 +142,6 @@ export * as PluggableAdmin from './pluggable-admin-api/pluggable-admin'; * other plugins. * @category Internal */ -export * as MockttpPluggableAdmin from './pluggable-admin-api/mockttp-pluggable-admin'; \ No newline at end of file +export * as MockttpPluggableAdmin from './pluggable-admin-api/mockttp-pluggable-admin'; + +export { RequestHandlerError } from "./types"; diff --git a/src/mockttp.ts b/src/mockttp.ts index 10be99b83..43afb9e00 100644 --- a/src/mockttp.ts +++ b/src/mockttp.ts @@ -15,7 +15,8 @@ import { TlsRequest, InitiatedRequest, ClientError, - RulePriority + RulePriority, + RequestHandlerError } from "./types"; import type { RequestRuleData } from "./rules/requests/request-rule"; import type { WebSocketRuleData } from "./rules/websockets/websocket-rule"; @@ -415,6 +416,19 @@ export interface Mockttp { */ on(event: 'client-error', callback: (error: ClientError) => void): Promise; + /** + * Subscribe to hear about requests that fail during request handling. + * + * This is useful in case of DNS resolution problems or connecting to the remote peer. + * + * The callback will be called asynchronously from request handling. This function + * returns a promise, and the callback is not guaranteed to be registered until + * the promise is resolved. + * + * @category Events + */ + on(event: 'request-handler-error', callback: (error: RequestHandlerError) => void): Promise; + /** * Adds the given rules to the server. * @@ -576,7 +590,8 @@ export type SubscribableEvent = | 'response' | 'abort' | 'tls-client-error' - | 'client-error'; + | 'client-error' + | 'request-handler-error'; /** * @hidden @@ -677,4 +692,4 @@ export abstract class AbstractMockttp { return new WebSocketRuleBuilder(this.addWebSocketRule); } -} \ No newline at end of file +} diff --git a/src/rules/requests/request-handler-definitions.ts b/src/rules/requests/request-handler-definitions.ts index 5b49baa35..8811415d6 100644 --- a/src/rules/requests/request-handler-definitions.ts +++ b/src/rules/requests/request-handler-definitions.ts @@ -985,4 +985,4 @@ export const HandlerDefinitionLookup = { 'passthrough': PassThroughHandlerDefinition, 'close-connection': CloseConnectionHandlerDefinition, 'timeout': TimeoutHandlerDefinition -} \ No newline at end of file +} diff --git a/src/rules/requests/request-handlers.ts b/src/rules/requests/request-handlers.ts index c2c22c65b..7ebd46d6a 100644 --- a/src/rules/requests/request-handlers.ts +++ b/src/rules/requests/request-handlers.ts @@ -1025,4 +1025,4 @@ export const HandlerLookup: typeof HandlerDefinitionLookup = { 'passthrough': PassThroughHandler, 'close-connection': CloseConnectionHandler, 'timeout': TimeoutHandler -} \ No newline at end of file +} diff --git a/src/server/mockttp-server.ts b/src/server/mockttp-server.ts index d79ba1f9d..f8aa4f995 100644 --- a/src/server/mockttp-server.ts +++ b/src/server/mockttp-server.ts @@ -20,7 +20,8 @@ import { TlsRequest, ClientError, TimingEvents, - OngoingBody + OngoingBody, + RequestHandlerError } from "../types"; import { CAOptions } from '../util/tls'; import { DestroyableServer } from "../util/destroyable-server"; @@ -277,6 +278,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp { public on(event: 'tls-client-error', callback: (req: TlsRequest) => void): Promise; public on(event: 'tlsClientError', callback: (req: TlsRequest) => void): Promise; public on(event: 'client-error', callback: (error: ClientError) => void): Promise; + public on(event: 'request-handler-error', callback: (error: RequestHandlerError) => void): Promise; public on(event: string, callback: (...args: any[]) => void): Promise { this.eventEmitter.on(event, callback); return Promise.resolve(); @@ -464,32 +466,37 @@ export class MockttpServer extends AbstractMockttp implements Mockttp { } result = result || 'responded'; } catch (e) { - if (e instanceof AbortError) { - abort(); - - if (this.debug) { - console.error("Failed to handle request due to abort:", e); - } - } else { - console.error("Failed to handle request:", - this.debug - ? e - : (isErrorLike(e) && e.message) || e - ); + if (this.eventEmitter.listeners('requerst-handle-error').length > 0) { + this.eventEmitter.emit('requerst-handle-error', e); + } + else { + if (e instanceof AbortError) { + abort(); - // Do whatever we can to tell the client we broke - try { - response.writeHead( - (isErrorLike(e) && e.statusCode) || 500, - (isErrorLike(e) && e.statusMessage) || 'Server error' + if (this.debug) { + console.error("Failed to handle request due to abort:", e); + } + } else { + console.error("Failed to handle request:", + this.debug + ? e + : (isErrorLike(e) && e.message) || e ); - } catch (e) {} - try { - response.end((isErrorLike(e) && e.toString()) || e); - result = result || 'responded'; - } catch (e) { - abort(); + // Do whatever we can to tell the client we broke + try { + response.writeHead( + (isErrorLike(e) && e.statusCode) || 500, + (isErrorLike(e) && e.statusMessage) || 'Server error' + ); + } catch (e) {} + + try { + response.end((isErrorLike(e) && e.toString()) || e); + result = result || 'responded'; + } catch (e) { + abort(); + } } } } @@ -818,4 +825,4 @@ ${await this.suggestRule(request)}` response: 'aborted' // These h2 errors get no app-level response, just a shutdown. }); } -} \ No newline at end of file +} diff --git a/src/types.ts b/src/types.ts index 3153cdb2b..64aa34100 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import stream = require('stream'); import http = require('http'); import { EventEmitter } from 'events'; +import { ErrorLike } from './util/error'; export const DEFAULT_ADMIN_SERVER_PORT = 45454; @@ -205,6 +206,11 @@ export interface ClientError { response: CompletedResponse | 'aborted'; } +/** + * RequestHandlerError dummy + */ + export interface RequestHandlerError extends ErrorLike {} + /** * A mocked endpoint provides methods to see the current state of * a mock rule. @@ -256,4 +262,4 @@ export interface ProxyEnvConfig { // A slightly weird one: this is necessary because we export types that inherit from EventEmitter, // so the docs include EventEmitter's methods, which @link to this type, that's otherwise not // defined in this module. Reexporting the values avoids warnings for that. -export type defaultMaxListeners = typeof EventEmitter.defaultMaxListeners; \ No newline at end of file +export type defaultMaxListeners = typeof EventEmitter.defaultMaxListeners; diff --git a/src/util/error.ts b/src/util/error.ts index d7a4c5527..19a02275f 100644 --- a/src/util/error.ts +++ b/src/util/error.ts @@ -15,4 +15,4 @@ export function isErrorLike(error: any): error is ErrorLike { error.code || error.stack ) -} \ No newline at end of file +}