Skip to content

Commit

Permalink
Add error field to aborted request events, when aborted by an error
Browse files Browse the repository at this point in the history
This applies to requests that were failed because the upstream
connection failed (more likely with the new error simulation option) or
because of connections that were intentionally closed by a rule.
  • Loading branch information
pimterry committed Oct 20, 2022
1 parent aabd24f commit 3ddcf06
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 36 deletions.
4 changes: 2 additions & 2 deletions src/admin/mockttp-admin-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ export function buildAdminServerModel(
mockServer.on('abort', (evt) => {
pubsub.publish(REQUEST_ABORTED_TOPIC, {
requestAborted: Object.assign(evt, {
// Backward compat: old clients expect this to be present. In future this can be removed
// and abort events can switch from Request to InitiatedRequest in the schema.
// Backward compat: old clients expect this to be present. In future this can be
// removed and abort events can lose the 'body' in the schema.
body: Buffer.alloc(0)
})
})
Expand Down
25 changes: 24 additions & 1 deletion src/admin/mockttp-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const MockttpSchema = gql`
webSocketMessageReceived: WebSocketMessage!
webSocketMessageSent: WebSocketMessage!
webSocketClose: WebSocketClose!
requestAborted: Request!
requestAborted: AbortedRequest!
failedTlsRequest: TlsRequest!
failedClientRequest: ClientError!
}
Expand Down Expand Up @@ -126,6 +126,29 @@ export const MockttpSchema = gql`
body: Buffer!
}
type AbortedRequest {
id: ID!
timingEvents: Json!
tags: [String!]!
matchedRuleId: ID
protocol: String!
httpVersion: String!
method: String!
url: String!
path: String!
remoteIpAddress: String!
remotePort: Int!
hostname: String
headers: Json!
rawHeaders: Json!
body: Buffer!
error: Json
}
type Response {
id: ID!
timingEvents: Json!
Expand Down
4 changes: 4 additions & 0 deletions src/client/mockttp-admin-request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ export class MockttpAdminRequestBuilder {
${this.schema.asOptionalField('Request', 'timingEvents')}
${this.schema.asOptionalField('Request', 'tags')}
${this.schema.asOptionalField('AbortedRequest', 'error')}
}
}`,
'tls-client-error': gql`subscription OnTlsClientError {
Expand Down Expand Up @@ -421,6 +422,9 @@ export class MockttpAdminRequestBuilder {
}
} else if (event === 'websocket-message-received' || event === 'websocket-message-sent') {
normalizeWebSocketMessage(data);
} else if (event === 'abort') {
normalizeHttpMessage(data, event);
data.error = data.error ? JSON.parse(data.error) : undefined;
} else {
normalizeHttpMessage(data, event);
}
Expand Down
5 changes: 3 additions & 2 deletions src/mockttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
ClientError,
RulePriority,
WebSocketMessage,
WebSocketClose
WebSocketClose,
AbortedRequest
} from "./types";
import type { RequestRuleData } from "./rules/requests/request-rule";
import type { WebSocketRuleData } from "./rules/websockets/websocket-rule";
Expand Down Expand Up @@ -466,7 +467,7 @@ export interface Mockttp {
*
* @category Events
*/
on(event: 'abort', callback: (req: InitiatedRequest) => void): Promise<void>;
on(event: 'abort', callback: (req: AbortedRequest) => void): Promise<void>;

/**
* Subscribe to hear about requests that start a TLS handshake, but fail to complete it.
Expand Down
24 changes: 18 additions & 6 deletions src/rules/requests/request-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,16 @@ export {

// An error that indicates that the handler is aborting the request.
// This could be intentional, or an upstream server aborting the request.
export class AbortError extends TypedError { }
export class AbortError extends TypedError {

constructor(
message: string,
readonly code?: string
) {
super(message);
}

}

function isSerializedBuffer(obj: any): obj is SerializedBuffer {
return obj && obj.type === 'Buffer' && !!obj.data;
Expand Down Expand Up @@ -184,7 +193,7 @@ export class CallbackHandler extends CallbackHandlerDefinition {

if (outResponse === 'close') {
(request as any).socket.end();
throw new AbortError('Connection closed (intentionally)');
throw new AbortError('Connection closed intentionally by rule');
} else {
await writeResponseFromCallback(outResponse, response);
}
Expand Down Expand Up @@ -553,7 +562,7 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
if (modifiedReq.response === 'close') {
const socket: net.Socket = (<any> clientReq).socket;
socket.end();
throw new AbortError('Connection closed (intentionally)');
throw new AbortError('Connection closed intentionally by rule');
} else {
// The callback has provided a full response: don't passthrough at all, just use it.
await writeResponseFromCallback(modifiedReq.response, clientRes);
Expand Down Expand Up @@ -825,7 +834,7 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
// Dump the real response data and kill the client socket:
serverRes.resume();
(clientRes as any).socket.end();
throw new AbortError('Connection closed (intentionally)');
throw new AbortError('Connection closed intentionally by rule');
}

validateCustomHeaders(serverHeaders, modifiedRes?.headers);
Expand Down Expand Up @@ -946,7 +955,10 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
} else {
socket.destroy();
}
reject(new AbortError('Upstream connection failed'));

reject(new AbortError(`Upstream connection error: ${
e.message ?? e
}`, e.code));
} else {
e.statusCode = 502;
e.statusMessage = 'Error communicating with upstream server';
Expand Down Expand Up @@ -1084,7 +1096,7 @@ export class CloseConnectionHandler extends CloseConnectionHandlerDefinition {
async handle(request: OngoingRequest) {
const socket: net.Socket = (<any> request).socket;
socket.end();
throw new AbortError('Connection closed (intentionally)');
throw new AbortError('Connection closed intentionally by rule');
}
}

Expand Down
24 changes: 15 additions & 9 deletions src/server/mockttp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { ServerMockedEndpoint } from "./mocked-endpoint";
import { createComboServer } from "./http-combo-server";
import { filter } from "../util/promise";
import { Mutable } from "../util/type-utils";
import { isErrorLike } from "../util/error";
import { ErrorLike, isErrorLike } from "../util/error";
import { makePropertyWritable } from "../util/util";

import {
Expand Down Expand Up @@ -480,12 +480,18 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
});
}

private async announceAbortAsync(request: OngoingRequest) {
private async announceAbortAsync(request: OngoingRequest, abortError?: ErrorLike) {
setImmediate(() => {
const req = buildInitiatedRequest(request);
this.eventEmitter.emit('abort', Object.assign(req, {
timingEvents: _.clone(req.timingEvents),
tags: _.clone(req.tags)
tags: _.clone(req.tags),
error: abortError ? {
name: abortError.name,
code: abortError.code,
message: abortError.message,
stack: abortError.stack
} : undefined
}));
});
}
Expand Down Expand Up @@ -582,18 +588,18 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
if (this.debug) console.log(`Handling request for ${rawRequest.url}`);

let result: 'responded' | 'aborted' | null = null;
const abort = () => {
const abort = (error?: Error) => {
if (result === null) {
result = 'aborted';
request.timingEvents.abortedTimestamp = now();
this.announceAbortAsync(request);
this.announceAbortAsync(request, error);
}
}
request.once('aborted', abort);
// In Node 16+ we don't get an abort event in many cases, just closes, but we know
// it's aborted because the response is closed with no other result being set.
rawResponse.once('close', () => setImmediate(abort));
request.once('error', () => setImmediate(abort));
request.once('error', (error) => setImmediate(() => abort(error)));

this.announceInitialRequestAsync(request);

Expand All @@ -606,7 +612,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
response.id = request.id;
response.on('error', (error) => {
console.log('Response error:', this.debug ? error : error.message);
abort();
abort(error);
});

try {
Expand All @@ -631,7 +637,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
result = result || 'responded';
} catch (e) {
if (e instanceof AbortError) {
abort();
abort(e);

if (this.debug) {
console.error("Failed to handle request due to abort:", e);
Expand All @@ -655,7 +661,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
response.end((isErrorLike(e) && e.toString()) || e);
result = result || 'responded';
} catch (e) {
abort();
abort(e as Error);
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ export interface InitiatedRequest extends Request {
timingEvents: TimingEvents;
}

export interface AbortedRequest extends InitiatedRequest {
error?: {
name?: string;
code?: string;
message?: string;
stack?: string;
};
}

// Internal & external representation of a fully completed HTTP request
export interface CompletedRequest extends Request {
body: CompletedBody;
Expand Down
Loading

0 comments on commit 3ddcf06

Please sign in to comment.