Skip to content

Commit

Permalink
feat: add "unhandledException" event (#566)
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito authored Apr 26, 2024
1 parent 8652556 commit a3afcf1
Show file tree
Hide file tree
Showing 8 changed files with 431 additions and 41 deletions.
11 changes: 11 additions & 0 deletions src/glossary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,15 @@ export type HttpRequestEventMap = {
requestId: string
}
]
unhandledException: [
args: {
error: unknown
request: Request
requestId: string
controller: {
respondWith(response: Response): void
errorWith(error?: Error): void
}
}
]
}
32 changes: 28 additions & 4 deletions src/interceptors/ClientRequest/NodeClientRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,34 @@ export class NodeClientRequest extends ClientRequest {
return this
}

// Unhandled exceptions in the request listeners are
// synonymous to unhandled exceptions on the server.
// Those are represented as 500 error responses.
this.respondWith(createServerErrorResponse(resolverResult.error))
until(async () => {
if (this.emitter.listenerCount('unhandledException') > 0) {
// Emit the "unhandledException" event to allow the client
// to opt-out from the default handling of exceptions
// as 500 error responses.
await emitAsync(this.emitter, 'unhandledException', {
error: resolverResult.error,
request: capturedRequest,
requestId,
controller: {
respondWith: this.respondWith.bind(this),
errorWith: this.errorWith.bind(this),
},
})

// If after the "unhandledException" listeners are done,
// the request is either not writable (was mocked) or
// destroyed (has errored), do nothing.
if (this.writableEnded || this.destroyed) {
return
}
}

// Unhandled exceptions in the request listeners are
// synonymous to unhandled exceptions on the server.
// Those are represented as 500 error responses.
this.respondWith(createServerErrorResponse(resolverResult.error))
})

return this
}
Expand Down
11 changes: 4 additions & 7 deletions src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,7 @@ export class XMLHttpRequestController {
private responseBuffer: Uint8Array
private events: Map<keyof XMLHttpRequestEventTargetEventMap, Array<Function>>

constructor(
readonly initialRequest: XMLHttpRequest,
public logger: Logger
) {
constructor(readonly initialRequest: XMLHttpRequest, public logger: Logger) {
this.events = new Map()
this.requestId = createRequestId()
this.requestHeaders = new Headers()
Expand Down Expand Up @@ -103,7 +100,7 @@ export class XMLHttpRequestController {
case 'addEventListener': {
const [eventName, listener] = args as [
keyof XMLHttpRequestEventTargetEventMap,
Function,
Function
]

this.registerEvent(eventName, listener)
Expand All @@ -123,7 +120,7 @@ export class XMLHttpRequestController {

case 'send': {
const [body] = args as [
body?: XMLHttpRequestBodyInit | Document | null,
body?: XMLHttpRequestBodyInit | Document | null
]

if (body != null) {
Expand Down Expand Up @@ -524,7 +521,7 @@ export class XMLHttpRequestController {
private trigger<
EventName extends keyof (XMLHttpRequestEventTargetEventMap & {
readystatechange: ProgressEvent<XMLHttpRequestEventTarget>
}),
})
>(eventName: EventName, options?: ProgressEventInit): void {
const callback = this.request[`on${eventName}`]
const event = createEvent(this.request, eventName, options)
Expand Down
25 changes: 25 additions & 0 deletions src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ export function createXMLHttpRequestProxy({

return
}

if (emitter.listenerCount('unhandledException') > 0) {
// Emit the "unhandledException" event so the client can opt-out
// from the default exception handling (producing 500 error responses).
await emitAsync(emitter, 'unhandledException', {
error: resolverResult.error,
request,
requestId,
controller: {
respondWith:
xhrRequestController.respondWith.bind(xhrRequestController),
errorWith:
xhrRequestController.errorWith.bind(xhrRequestController),
},
})

// If any of the "unhandledException" listeners handled the request,
// do nothing. Note that mocked responses will dispatch
// HEADERS_RECEIVED (2), then LOADING (3), and DONE (4) can take
// time as the mocked response body finishes streaming.
if (originalRequest.readyState > XMLHttpRequest.OPENED) {
return
}
}

// Unhandled exceptions in the request listeners are
// synonymous to unhandled exceptions on the server.
// Those are represented as 500 error responses.
Expand Down
108 changes: 79 additions & 29 deletions src/interceptors/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,26 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
)
}

const respondWith = (response: Response): Response => {
// Clone the mocked response for the "response" event listener.
// This way, the listener can read the response and not lock its body
// for the actual fetch consumer.
const responseClone = response.clone()

this.emitter.emit('response', {
response: responseClone,
isMockedResponse: true,
request: interactiveRequest,
requestId,
})
const responsePromise = new DeferredPromise<Response>()

const respondWith = (response: Response): void => {
this.logger.info('responding with a mock response:', response)

if (this.emitter.listenerCount('response') > 0) {
this.logger.info('emitting the "response" event...')

// Clone the mocked response for the "response" event listener.
// This way, the listener can read the response and not lock its body
// for the actual fetch consumer.
const responseClone = response.clone()

this.emitter.emit('response', {
response: responseClone,
isMockedResponse: true,
request: interactiveRequest,
requestId,
})
}

// Set the "response.url" property to equal the intercepted request URL.
Object.defineProperty(response, 'url', {
Expand All @@ -109,7 +117,11 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
value: request.url,
})

return response
responsePromise.resolve(response)
}

const errorWith = (reason: unknown): void => {
responsePromise.reject(reason)
}

const resolverResult = await until<unknown, Response | undefined>(
Expand Down Expand Up @@ -138,25 +150,56 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
)

if (requestAborted.state === 'rejected') {
return Promise.reject(requestAborted.rejectionReason)
this.logger.info(
'request has been aborted:',
requestAborted.rejectionReason
)

responsePromise.reject(requestAborted.rejectionReason)
return responsePromise
}

if (resolverResult.error) {
this.logger.info(
'request listerner threw an error:',
resolverResult.error
)

// Treat thrown Responses as mocked responses.
if (resolverResult.error instanceof Response) {
// Treat thrown Response.error() as a request error.
if (isResponseError(resolverResult.error)) {
return Promise.reject(createNetworkError(resolverResult.error))
errorWith(createNetworkError(resolverResult.error))
} else {
// Treat the rest of thrown Responses as mocked responses.
respondWith(resolverResult.error)
}
}

// Emit the "unhandledException" interceptor event so the client
// can opt-out from exceptions translating to 500 error responses.

if (this.emitter.listenerCount('unhandledException') > 0) {
await emitAsync(this.emitter, 'unhandledException', {
error: resolverResult.error,
request,
requestId,
controller: {
respondWith,
errorWith,
},
})

// Treat the rest of thrown Responses as mocked responses.
return respondWith(resolverResult.error)
if (responsePromise.state !== 'pending') {
return responsePromise
}
}

// Unhandled exceptions in the request listeners are
// synonymous to unhandled exceptions on the server.
// Those are represented as 500 error responses.
return createServerErrorResponse(resolverResult.error)
respondWith(createServerErrorResponse(resolverResult.error))
return responsePromise
}

const mockedResponse = resolverResult.data
Expand All @@ -178,24 +221,31 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
* "response.error" will equal to undefined, making "cause" an empty Error.
* @see https://github.com/nodejs/undici/blob/83cb522ae0157a19d149d72c7d03d46e34510d0a/lib/fetch/response.js#L344
*/
return Promise.reject(createNetworkError(mockedResponse))
errorWith(createNetworkError(mockedResponse))
} else {
respondWith(mockedResponse)
}

return respondWith(mockedResponse)
return responsePromise
}

this.logger.info('no mocked response received!')

return pureFetch(request).then((response) => {
const responseClone = response.clone()
this.logger.info('original fetch performed', responseClone)

this.emitter.emit('response', {
response: responseClone,
isMockedResponse: false,
request: interactiveRequest,
requestId,
})
this.logger.info('original fetch performed', response)

if (this.emitter.listenerCount('response') > 0) {
this.logger.info('emitting the "response" event...')

const responseClone = response.clone()

this.emitter.emit('response', {
response: responseClone,
isMockedResponse: false,
request: interactiveRequest,
requestId,
})
}

return response
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,101 @@ it('treats a thrown Response.error() as a network error', async () => {
expect(request.status).toBe(0)
expect(requestErrorListener).toHaveBeenCalledTimes(1)
})

it('handles exceptions by default if "unhandledException" listener is provided but does nothing', async () => {
const unhandledExceptionListener = vi.fn()

interceptor.on('request', () => {
throw new Error('Custom error')
})
interceptor.on('unhandledException', unhandledExceptionListener)

const request = await createXMLHttpRequest((request) => {
request.responseType = 'json'
request.open('GET', 'http://localhost/api')
request.send()
})

// Must emit the "unhandledException" interceptor event.
expect(unhandledExceptionListener).toHaveBeenCalledWith(
expect.objectContaining({
error: new Error('Custom error'),
})
)
expect(unhandledExceptionListener).toHaveBeenCalledOnce()

expect(request.status).toBe(500)
expect(request.statusText).toBe('Unhandled Exception')
expect(request.response).toEqual({
name: 'Error',
message: 'Custom error',
stack: expect.any(String),
})
})

it('handles exceptions as instructed in "unhandledException" listener (mock response)', async () => {
const unhandledExceptionListener = vi.fn()

interceptor.on('request', () => {
throw new Error('Custom error')
})
interceptor.on('unhandledException', (args) => {
const { controller } = args
unhandledExceptionListener(args)

// Handle exceptions as a fallback 200 OK response.
controller.respondWith(new Response('fallback response'))
})

const requestErrorListener = vi.fn()
const request = await createXMLHttpRequest((request) => {
request.responseType = 'text'
request.open('GET', 'http://localhost/api')
request.addEventListener('error', requestErrorListener)
request.send()
})

expect(request.status).toBe(200)
expect(request.response).toBe('fallback response')

expect(unhandledExceptionListener).toHaveBeenCalledWith(
expect.objectContaining({
error: new Error('Custom error'),
})
)
expect(unhandledExceptionListener).toHaveBeenCalledOnce()
expect(requestErrorListener).not.toHaveBeenCalled()
})

it('handles exceptions as instructed in "unhandledException" listener (request error)', async () => {
const unhandledExceptionListener = vi.fn()

interceptor.on('request', () => {
throw new Error('Custom error')
})
interceptor.on('unhandledException', (args) => {
const { request, controller } = args
unhandledExceptionListener(args)

// Handle exceptions as request errors.
controller.errorWith(new Error('Fallback error'))
})

const requestErrorListener = vi.fn()
const request = await createXMLHttpRequest((request) => {
request.responseType = 'text'
request.open('GET', 'http://localhost/api')
request.addEventListener('error', requestErrorListener)
request.send()
})

expect(requestErrorListener).toHaveBeenCalledOnce()
expect(request.readyState).toBe(request.DONE)

expect(unhandledExceptionListener).toHaveBeenCalledWith(
expect.objectContaining({
error: new Error('Custom error'),
})
)
expect(unhandledExceptionListener).toHaveBeenCalledOnce()
})
Loading

0 comments on commit a3afcf1

Please sign in to comment.