From bf360bbd1ba1bcf217acdcc4a28a7672787814ac Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 12 Jul 2025 08:35:09 +0200 Subject: [PATCH 1/9] feat(proxy): add fallback response handling in onError hook --- README.md | 22 +- src/proxy.ts | 10 +- src/types.ts | 6 +- tests/proxy-fallback.test.ts | 434 +++++++++++++++++++++++++++++++++++ 4 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 tests/proxy-fallback.test.ts diff --git a/README.md b/README.md index 6afee74..d469d15 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,10 @@ interface ProxyRequestOptions { res: Response, body?: ReadableStream | null, ) => void | Promise - onError?: (req: Request, error: Error) => void | Promise + onError?: ( + req: Request, + error: Error, + ) => void | Promise | Promise beforeCircuitBreakerExecution?: ( req: Request, opts: ProxyRequestOptions, @@ -547,6 +550,23 @@ proxy(req, undefined, { }) ``` +#### Returning Fallback Responses + +You can return a fallback response from the `onError` hook by resolving the hook with a `Response` object. This allows you to customize the error response sent to the client. + +```typescript +proxy(req, undefined, { + onError: async (req, error) => { + // Log error + console.error("Proxy error:", error) + + // Return a fallback response + console.log("Returning fallback response for:", req.url) + return new Response("Fallback response", { status: 200 }) + }, +}) +``` + ## Performance Tips 1. **URL Caching**: Keep `cacheURLs` enabled (default 100) for better performance diff --git a/src/proxy.ts b/src/proxy.ts index ed4efaa..21a13f8 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -166,8 +166,9 @@ export class FetchProxy { currentLogger.logRequestError(req, err, { requestId, executionTime }) // Execute error hooks + let fallbackResponse: Response | void = undefined if (options.onError) { - await options.onError(req, err) + fallbackResponse = await options.onError(req, err) } // Execute circuit breaker completion hooks for failures @@ -179,12 +180,17 @@ export class FetchProxy { state: this.circuitBreaker.getState(), failureCount: this.circuitBreaker.getFailures(), executionTimeMs: executionTime, + fallbackResponse, }, options, ) + if (fallbackResponse) { + // If onError provided a fallback response, return it + return fallbackResponse + } // Return appropriate error response - if (err.message.includes("Circuit breaker is OPEN")) { + else if (err.message.includes("Circuit breaker is OPEN")) { return new Response("Service Unavailable", { status: 503 }) } else if ( err.message.includes("timeout") || diff --git a/src/types.ts b/src/types.ts index bbbccdb..0dabf3b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,7 +83,10 @@ export type AfterCircuitBreakerHook = ( result: CircuitBreakerResult, ) => void | Promise -export type ErrorHook = (req: Request, error: Error) => void | Promise +export type ErrorHook = ( + req: Request, + error: Error, +) => void | Promise | Promise // Circuit breaker result information export interface CircuitBreakerResult { @@ -92,6 +95,7 @@ export interface CircuitBreakerResult { state: CircuitState failureCount: number executionTimeMs: number + fallbackResponse?: Response | void } export enum CircuitState { diff --git a/tests/proxy-fallback.test.ts b/tests/proxy-fallback.test.ts new file mode 100644 index 0000000..68241ba --- /dev/null +++ b/tests/proxy-fallback.test.ts @@ -0,0 +1,434 @@ +/** + * Tests for proxy fallback response using onError hook + */ + +import { + describe, + expect, + it, + beforeEach, + jest, + afterAll, + mock, +} from "bun:test" +import { FetchProxy } from "../src/proxy" +import type { ProxyRequestOptions } from "../src/types" + +// Mock fetch for testing +const mockFetch = jest.fn() +;(global as any).fetch = mockFetch + +afterAll(() => { + mock.restore() +}) + +describe("Proxy Fallback Response", () => { + let proxy: FetchProxy + + beforeEach(() => { + proxy = new FetchProxy({ + base: "https://api.example.com", + timeout: 5000, + }) + mockFetch.mockClear() + }) + + describe("onError Hook Fallback", () => { + it("should return fallback response when onError hook provides one", async () => { + // Mock a network error + mockFetch.mockRejectedValue(new Error("Network error")) + + const fallbackResponse = new Response( + JSON.stringify({ + message: "Service temporarily unavailable", + fallback: true + }), + { + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "application/json" }), + } + ) + + const onErrorHook = jest.fn().mockResolvedValue(fallbackResponse) + + const request = new Request("https://example.com/test") + const response = await proxy.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(onErrorHook).toHaveBeenCalledWith( + expect.any(Request), + expect.any(Error) + ) + expect(response).toBe(fallbackResponse) + expect(response.status).toBe(200) + + const body = await response.json() as { fallback: boolean } + expect(body.fallback).toBe(true) + }) + + it("should handle async fallback response generation", async () => { + mockFetch.mockRejectedValue(new Error("Timeout error")) + + const onErrorHook = jest.fn().mockImplementation(async (req, error) => { + // Simulate async fallback logic + await new Promise(resolve => setTimeout(resolve, 10)) + + return new Response( + JSON.stringify({ + error: "Service unavailable", + timestamp: Date.now(), + originalUrl: req.url, + }), + { + status: 503, + statusText: "Service Unavailable", + headers: new Headers({ "content-type": "application/json" }), + } + ) + }) + + const request = new Request("https://example.com/test") + const response = await proxy.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(onErrorHook).toHaveBeenCalledWith( + expect.any(Request), + expect.any(Error) + ) + expect(response.status).toBe(503) + + const body = await response.json() as { error: string; originalUrl: string } + expect(body.error).toBe("Service unavailable") + expect(body.originalUrl).toBe("https://example.com/test") + }) + + it("should fallback to default error response when onError hook returns void", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + const onErrorHook = jest.fn().mockResolvedValue(undefined) + + const request = new Request("https://example.com/test") + const response = await proxy.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(onErrorHook).toHaveBeenCalledWith( + expect.any(Request), + expect.any(Error) + ) + expect(response.status).toBe(502) // Default error response + }) + + it("should handle different error types with appropriate fallbacks", async () => { + const testCases = [ + { + error: new Error("timeout"), + expectedStatus: 504, + fallbackStatus: 408, + fallbackMessage: "Request timeout - try again later" + }, + { + error: new Error("Circuit breaker is OPEN"), + expectedStatus: 503, + fallbackStatus: 503, + fallbackMessage: "Service temporarily unavailable" + }, + { + error: new Error("Network error"), + expectedStatus: 502, + fallbackStatus: 500, + fallbackMessage: "Internal server error" + } + ] + + for (const testCase of testCases) { + mockFetch.mockRejectedValue(testCase.error) + + const onErrorHook = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ message: testCase.fallbackMessage }), + { + status: testCase.fallbackStatus, + headers: new Headers({ "content-type": "application/json" }), + } + ) + ) + + const request = new Request("https://example.com/test") + const response = await proxy.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(response.status).toBe(testCase.fallbackStatus) + + const body = await response.json() as { message: string } + expect(body.message).toBe(testCase.fallbackMessage) + } + }) + + it("should handle circuit breaker with fallback response", async () => { + const proxyWithCircuitBreaker = new FetchProxy({ + base: "https://api.example.com", + circuitBreaker: { + failureThreshold: 1, + resetTimeout: 1000, + enabled: true, + }, + }) + + // First request fails to trigger circuit breaker + mockFetch.mockRejectedValue(new Error("Service error")) + + const onErrorHook = jest.fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + message: "Using cached data", + data: { cached: true }, + source: "fallback" + }), + { + status: 200, + headers: new Headers({ "content-type": "application/json" }), + } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + message: "Using cached data", + data: { cached: true }, + source: "fallback" + }), + { + status: 200, + headers: new Headers({ "content-type": "application/json" }), + } + ) + ) + + const request = new Request("https://example.com/test") + + // First request - should fail and trigger circuit breaker + const response1 = await proxyWithCircuitBreaker.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(response1.status).toBe(200) + const body1 = await response1.json() as { source: string } + expect(body1.source).toBe("fallback") + + // Second request - circuit breaker should be open + const response2 = await proxyWithCircuitBreaker.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(response2.status).toBe(200) + const body2 = await response2.json() as { source: string } + expect(body2.source).toBe("fallback") + }) + + it("should pass correct request and error objects to onError hook", async () => { + const networkError = new Error("ECONNREFUSED") + mockFetch.mockRejectedValue(networkError) + + const onErrorHook = jest.fn().mockResolvedValue( + new Response("Fallback response", { status: 200 }) + ) + + const originalRequest = new Request("https://example.com/test", { + method: "POST", + headers: { "X-Custom": "value" }, + body: JSON.stringify({ test: "data" }), + }) + + await proxy.proxy(originalRequest, "/api/data", { + onError: onErrorHook, + }) + + expect(onErrorHook).toHaveBeenCalledWith( + expect.any(Request), + networkError + ) + + // Check the actual URL passed to the hook (original request URL, not target URL) + const actualRequest = onErrorHook.mock.calls[0][0] + expect(actualRequest.url).toBe("https://example.com/test") + expect(actualRequest.method).toBe("POST") + }) + + it("should handle multiple concurrent requests with fallback", async () => { + mockFetch.mockRejectedValue(new Error("Service unavailable")) + + const onErrorHook = jest.fn().mockImplementation(async (req, error) => { + return new Response( + JSON.stringify({ + message: "Fallback response", + requestId: Math.random().toString(36).substr(2, 9), + timestamp: Date.now(), + }), + { + status: 200, + headers: new Headers({ "content-type": "application/json" }), + } + ) + }) + + const requests = Array.from({ length: 5 }, (_, i) => + new Request(`https://example.com/test${i}`) + ) + + const responses = await Promise.all( + requests.map(req => + proxy.proxy(req, `/api/data${req.url.slice(-1)}`, { + onError: onErrorHook, + }) + ) + ) + + expect(onErrorHook).toHaveBeenCalledTimes(5) + + for (const response of responses) { + expect(response.status).toBe(200) + const body = await response.json() as { message: string; requestId: string } + expect(body.message).toBe("Fallback response") + expect(body.requestId).toBeDefined() + } + }) + + it("should handle onError hook that throws an error", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + const onErrorHook = jest.fn().mockImplementation(async () => { + throw new Error("Hook error") + }) + + const request = new Request("https://example.com/test") + + try { + await proxy.proxy(request, "/api/data", { + onError: onErrorHook, + }) + // If we reach here, the test should fail + expect(true).toBe(false) + } catch (error) { + expect(onErrorHook).toHaveBeenCalled() + expect((error as Error).message).toBe("Hook error") + } + }) + + it("should handle fallback response with custom headers", async () => { + mockFetch.mockRejectedValue(new Error("Service error")) + + const onErrorHook = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ fallback: true }), + { + status: 200, + headers: new Headers({ + "content-type": "application/json", + "x-fallback": "true", + "x-timestamp": Date.now().toString(), + "cache-control": "no-cache", + }), + } + ) + ) + + const request = new Request("https://example.com/test") + const response = await proxy.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(response.status).toBe(200) + expect(response.headers.get("x-fallback")).toBe("true") + expect(response.headers.get("x-timestamp")).toBeTruthy() + expect(response.headers.get("cache-control")).toBe("no-cache") + }) + + it("should handle streaming fallback response", async () => { + mockFetch.mockRejectedValue(new Error("Streaming error")) + + const onErrorHook = jest.fn().mockImplementation(async (req, error) => { + const stream = new ReadableStream({ + start(controller) { + const data = JSON.stringify({ + message: "Fallback stream", + chunks: ["chunk1", "chunk2", "chunk3"] + }) + controller.enqueue(new TextEncoder().encode(data)) + controller.close() + } + }) + + return new Response(stream, { + status: 200, + headers: new Headers({ "content-type": "application/json" }), + }) + }) + + const request = new Request("https://example.com/test") + const response = await proxy.proxy(request, "/api/data", { + onError: onErrorHook, + }) + + expect(response.status).toBe(200) + + const body = await response.json() as { message: string; chunks: string[] } + expect(body.message).toBe("Fallback stream") + expect(body.chunks).toEqual(["chunk1", "chunk2", "chunk3"]) + }) + }) + + describe("Integration with Other Features", () => { + it("should work with beforeRequest and afterResponse hooks", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + const beforeRequestHook = jest.fn() + const afterResponseHook = jest.fn() + const onErrorHook = jest.fn().mockResolvedValue( + new Response("Fallback", { status: 200 }) + ) + + const request = new Request("https://example.com/test") + const response = await proxy.proxy(request, "/api/data", { + beforeRequest: beforeRequestHook, + afterResponse: afterResponseHook, + onError: onErrorHook, + }) + + expect(beforeRequestHook).toHaveBeenCalled() + expect(onErrorHook).toHaveBeenCalled() + // afterResponse hook should NOT be called for error responses + expect(afterResponseHook).not.toHaveBeenCalled() + expect(response.status).toBe(200) + }) + + it("should work with custom headers and query parameters", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + const onErrorHook = jest.fn().mockResolvedValue( + new Response("Fallback", { status: 200 }) + ) + + const request = new Request("https://example.com/test") + await proxy.proxy(request, "/api/data", { + headers: { "X-Custom": "test" }, + queryString: { param: "value" }, + onError: onErrorHook, + }) + + expect(onErrorHook).toHaveBeenCalledWith( + expect.any(Request), + expect.any(Error) + ) + + // Check the actual URL passed to the hook (original request URL, not target URL) + const actualRequest = onErrorHook.mock.calls[0][0] + expect(actualRequest.url).toBe("https://example.com/test") + }) + }) +}) \ No newline at end of file From a4b0950bd2f3dd89511e3d0ef844499ed9260c9d Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 12 Jul 2025 08:40:20 +0200 Subject: [PATCH 2/9] refactor(tests): clean up formatting and improve readability in proxy fallback tests --- tests/proxy-fallback.test.ts | 186 +++++++++++++++++++---------------- 1 file changed, 99 insertions(+), 87 deletions(-) diff --git a/tests/proxy-fallback.test.ts b/tests/proxy-fallback.test.ts index 68241ba..53bf5d6 100644 --- a/tests/proxy-fallback.test.ts +++ b/tests/proxy-fallback.test.ts @@ -12,7 +12,6 @@ import { mock, } from "bun:test" import { FetchProxy } from "../src/proxy" -import type { ProxyRequestOptions } from "../src/types" // Mock fetch for testing const mockFetch = jest.fn() @@ -39,15 +38,15 @@ describe("Proxy Fallback Response", () => { mockFetch.mockRejectedValue(new Error("Network error")) const fallbackResponse = new Response( - JSON.stringify({ + JSON.stringify({ message: "Service temporarily unavailable", - fallback: true + fallback: true, }), { status: 200, statusText: "OK", headers: new Headers({ "content-type": "application/json" }), - } + }, ) const onErrorHook = jest.fn().mockResolvedValue(fallbackResponse) @@ -59,12 +58,12 @@ describe("Proxy Fallback Response", () => { expect(onErrorHook).toHaveBeenCalledWith( expect.any(Request), - expect.any(Error) + expect.any(Error), ) expect(response).toBe(fallbackResponse) expect(response.status).toBe(200) - - const body = await response.json() as { fallback: boolean } + + const body = (await response.json()) as { fallback: boolean } expect(body.fallback).toBe(true) }) @@ -73,10 +72,10 @@ describe("Proxy Fallback Response", () => { const onErrorHook = jest.fn().mockImplementation(async (req, error) => { // Simulate async fallback logic - await new Promise(resolve => setTimeout(resolve, 10)) - + await new Promise((resolve) => setTimeout(resolve, 10)) + return new Response( - JSON.stringify({ + JSON.stringify({ error: "Service unavailable", timestamp: Date.now(), originalUrl: req.url, @@ -85,7 +84,7 @@ describe("Proxy Fallback Response", () => { status: 503, statusText: "Service Unavailable", headers: new Headers({ "content-type": "application/json" }), - } + }, ) }) @@ -96,11 +95,14 @@ describe("Proxy Fallback Response", () => { expect(onErrorHook).toHaveBeenCalledWith( expect.any(Request), - expect.any(Error) + expect.any(Error), ) expect(response.status).toBe(503) - - const body = await response.json() as { error: string; originalUrl: string } + + const body = (await response.json()) as { + error: string + originalUrl: string + } expect(body.error).toBe("Service unavailable") expect(body.originalUrl).toBe("https://example.com/test") }) @@ -117,7 +119,7 @@ describe("Proxy Fallback Response", () => { expect(onErrorHook).toHaveBeenCalledWith( expect.any(Request), - expect.any(Error) + expect.any(Error), ) expect(response.status).toBe(502) // Default error response }) @@ -128,33 +130,30 @@ describe("Proxy Fallback Response", () => { error: new Error("timeout"), expectedStatus: 504, fallbackStatus: 408, - fallbackMessage: "Request timeout - try again later" + fallbackMessage: "Request timeout - try again later", }, { error: new Error("Circuit breaker is OPEN"), expectedStatus: 503, fallbackStatus: 503, - fallbackMessage: "Service temporarily unavailable" + fallbackMessage: "Service temporarily unavailable", }, { error: new Error("Network error"), expectedStatus: 502, fallbackStatus: 500, - fallbackMessage: "Internal server error" - } + fallbackMessage: "Internal server error", + }, ] for (const testCase of testCases) { mockFetch.mockRejectedValue(testCase.error) const onErrorHook = jest.fn().mockResolvedValue( - new Response( - JSON.stringify({ message: testCase.fallbackMessage }), - { - status: testCase.fallbackStatus, - headers: new Headers({ "content-type": "application/json" }), - } - ) + new Response(JSON.stringify({ message: testCase.fallbackMessage }), { + status: testCase.fallbackStatus, + headers: new Headers({ "content-type": "application/json" }), + }), ) const request = new Request("https://example.com/test") @@ -163,8 +162,8 @@ describe("Proxy Fallback Response", () => { }) expect(response.status).toBe(testCase.fallbackStatus) - - const body = await response.json() as { message: string } + + const body = (await response.json()) as { message: string } expect(body.message).toBe(testCase.fallbackMessage) } }) @@ -182,52 +181,61 @@ describe("Proxy Fallback Response", () => { // First request fails to trigger circuit breaker mockFetch.mockRejectedValue(new Error("Service error")) - const onErrorHook = jest.fn() + const onErrorHook = jest + .fn() .mockResolvedValueOnce( new Response( - JSON.stringify({ + JSON.stringify({ message: "Using cached data", data: { cached: true }, - source: "fallback" + source: "fallback", }), { status: 200, headers: new Headers({ "content-type": "application/json" }), - } - ) + }, + ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ + JSON.stringify({ message: "Using cached data", data: { cached: true }, - source: "fallback" + source: "fallback", }), { status: 200, headers: new Headers({ "content-type": "application/json" }), - } - ) + }, + ), ) const request = new Request("https://example.com/test") - + // First request - should fail and trigger circuit breaker - const response1 = await proxyWithCircuitBreaker.proxy(request, "/api/data", { - onError: onErrorHook, - }) + const response1 = await proxyWithCircuitBreaker.proxy( + request, + "/api/data", + { + onError: onErrorHook, + }, + ) expect(response1.status).toBe(200) - const body1 = await response1.json() as { source: string } + const body1 = (await response1.json()) as { source: string } expect(body1.source).toBe("fallback") // Second request - circuit breaker should be open - const response2 = await proxyWithCircuitBreaker.proxy(request, "/api/data", { - onError: onErrorHook, - }) + const response2 = await proxyWithCircuitBreaker.proxy( + request, + "/api/data", + { + onError: onErrorHook, + }, + ) expect(response2.status).toBe(200) - const body2 = await response2.json() as { source: string } + const body2 = (await response2.json()) as { source: string } expect(body2.source).toBe("fallback") }) @@ -235,9 +243,9 @@ describe("Proxy Fallback Response", () => { const networkError = new Error("ECONNREFUSED") mockFetch.mockRejectedValue(networkError) - const onErrorHook = jest.fn().mockResolvedValue( - new Response("Fallback response", { status: 200 }) - ) + const onErrorHook = jest + .fn() + .mockResolvedValue(new Response("Fallback response", { status: 200 })) const originalRequest = new Request("https://example.com/test", { method: "POST", @@ -251,9 +259,9 @@ describe("Proxy Fallback Response", () => { expect(onErrorHook).toHaveBeenCalledWith( expect.any(Request), - networkError + networkError, ) - + // Check the actual URL passed to the hook (original request URL, not target URL) const actualRequest = onErrorHook.mock.calls[0][0] expect(actualRequest.url).toBe("https://example.com/test") @@ -265,7 +273,7 @@ describe("Proxy Fallback Response", () => { const onErrorHook = jest.fn().mockImplementation(async (req, error) => { return new Response( - JSON.stringify({ + JSON.stringify({ message: "Fallback response", requestId: Math.random().toString(36).substr(2, 9), timestamp: Date.now(), @@ -273,27 +281,31 @@ describe("Proxy Fallback Response", () => { { status: 200, headers: new Headers({ "content-type": "application/json" }), - } + }, ) }) - const requests = Array.from({ length: 5 }, (_, i) => - new Request(`https://example.com/test${i}`) + const requests = Array.from( + { length: 5 }, + (_, i) => new Request(`https://example.com/test${i}`), ) const responses = await Promise.all( - requests.map(req => + requests.map((req) => proxy.proxy(req, `/api/data${req.url.slice(-1)}`, { onError: onErrorHook, - }) - ) + }), + ), ) expect(onErrorHook).toHaveBeenCalledTimes(5) - + for (const response of responses) { expect(response.status).toBe(200) - const body = await response.json() as { message: string; requestId: string } + const body = (await response.json()) as { + message: string + requestId: string + } expect(body.message).toBe("Fallback response") expect(body.requestId).toBeDefined() } @@ -307,7 +319,7 @@ describe("Proxy Fallback Response", () => { }) const request = new Request("https://example.com/test") - + try { await proxy.proxy(request, "/api/data", { onError: onErrorHook, @@ -324,18 +336,15 @@ describe("Proxy Fallback Response", () => { mockFetch.mockRejectedValue(new Error("Service error")) const onErrorHook = jest.fn().mockResolvedValue( - new Response( - JSON.stringify({ fallback: true }), - { - status: 200, - headers: new Headers({ - "content-type": "application/json", - "x-fallback": "true", - "x-timestamp": Date.now().toString(), - "cache-control": "no-cache", - }), - } - ) + new Response(JSON.stringify({ fallback: true }), { + status: 200, + headers: new Headers({ + "content-type": "application/json", + "x-fallback": "true", + "x-timestamp": Date.now().toString(), + "cache-control": "no-cache", + }), + }), ) const request = new Request("https://example.com/test") @@ -355,13 +364,13 @@ describe("Proxy Fallback Response", () => { const onErrorHook = jest.fn().mockImplementation(async (req, error) => { const stream = new ReadableStream({ start(controller) { - const data = JSON.stringify({ + const data = JSON.stringify({ message: "Fallback stream", - chunks: ["chunk1", "chunk2", "chunk3"] + chunks: ["chunk1", "chunk2", "chunk3"], }) controller.enqueue(new TextEncoder().encode(data)) controller.close() - } + }, }) return new Response(stream, { @@ -376,8 +385,11 @@ describe("Proxy Fallback Response", () => { }) expect(response.status).toBe(200) - - const body = await response.json() as { message: string; chunks: string[] } + + const body = (await response.json()) as { + message: string + chunks: string[] + } expect(body.message).toBe("Fallback stream") expect(body.chunks).toEqual(["chunk1", "chunk2", "chunk3"]) }) @@ -389,9 +401,9 @@ describe("Proxy Fallback Response", () => { const beforeRequestHook = jest.fn() const afterResponseHook = jest.fn() - const onErrorHook = jest.fn().mockResolvedValue( - new Response("Fallback", { status: 200 }) - ) + const onErrorHook = jest + .fn() + .mockResolvedValue(new Response("Fallback", { status: 200 })) const request = new Request("https://example.com/test") const response = await proxy.proxy(request, "/api/data", { @@ -410,9 +422,9 @@ describe("Proxy Fallback Response", () => { it("should work with custom headers and query parameters", async () => { mockFetch.mockRejectedValue(new Error("Network error")) - const onErrorHook = jest.fn().mockResolvedValue( - new Response("Fallback", { status: 200 }) - ) + const onErrorHook = jest + .fn() + .mockResolvedValue(new Response("Fallback", { status: 200 })) const request = new Request("https://example.com/test") await proxy.proxy(request, "/api/data", { @@ -423,12 +435,12 @@ describe("Proxy Fallback Response", () => { expect(onErrorHook).toHaveBeenCalledWith( expect.any(Request), - expect.any(Error) + expect.any(Error), ) - + // Check the actual URL passed to the hook (original request URL, not target URL) const actualRequest = onErrorHook.mock.calls[0][0] expect(actualRequest.url).toBe("https://example.com/test") }) }) -}) \ No newline at end of file +}) From 0a0e60adc7000f325189ae206601b32b68aac6cb Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 13 Jul 2025 09:50:56 +0200 Subject: [PATCH 3/9] feat(package): add actions script for local Docker context execution --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index dc72168..130298a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "clean": "rm -rf lib/", "prepublishOnly": "bun run clean && bun run build", "example:benchmark": "bun run examples/local-gateway-server.ts", - "deploy": "bun run prepublishOnly && bun publish" + "deploy": "bun run prepublishOnly && bun publish", + "actions": "DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}') act" }, "repository": { "type": "git", From 17e19e41fa94817bf5f12d7c1f7e8a4c5da6657f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 13 Jul 2025 09:51:05 +0200 Subject: [PATCH 4/9] fix(proxy): ensure fallback response is an instance of Response --- src/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy.ts b/src/proxy.ts index 21a13f8..1d1721b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -185,7 +185,7 @@ export class FetchProxy { options, ) - if (fallbackResponse) { + if (fallbackResponse instanceof Response) { // If onError provided a fallback response, return it return fallbackResponse } From 52d3913d2152daffc9b82a036326c1212141e485 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 13 Jul 2025 09:51:10 +0200 Subject: [PATCH 5/9] refactor(tests): replace mock fetch with spy for improved test accuracy --- tests/proxy-fallback.test.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/proxy-fallback.test.ts b/tests/proxy-fallback.test.ts index 53bf5d6..2e3360f 100644 --- a/tests/proxy-fallback.test.ts +++ b/tests/proxy-fallback.test.ts @@ -9,16 +9,15 @@ import { beforeEach, jest, afterAll, - mock, + spyOn, } from "bun:test" import { FetchProxy } from "../src/proxy" -// Mock fetch for testing -const mockFetch = jest.fn() -;(global as any).fetch = mockFetch +// Spy on fetch for testing +let fetchSpy: ReturnType afterAll(() => { - mock.restore() + fetchSpy?.mockRestore() }) describe("Proxy Fallback Response", () => { @@ -29,13 +28,14 @@ describe("Proxy Fallback Response", () => { base: "https://api.example.com", timeout: 5000, }) - mockFetch.mockClear() + fetchSpy = spyOn(global, "fetch") + fetchSpy.mockClear() }) describe("onError Hook Fallback", () => { it("should return fallback response when onError hook provides one", async () => { // Mock a network error - mockFetch.mockRejectedValue(new Error("Network error")) + fetchSpy.mockRejectedValue(new Error("Network error")) const fallbackResponse = new Response( JSON.stringify({ @@ -68,7 +68,7 @@ describe("Proxy Fallback Response", () => { }) it("should handle async fallback response generation", async () => { - mockFetch.mockRejectedValue(new Error("Timeout error")) + fetchSpy.mockRejectedValue(new Error("Timeout error")) const onErrorHook = jest.fn().mockImplementation(async (req, error) => { // Simulate async fallback logic @@ -108,7 +108,7 @@ describe("Proxy Fallback Response", () => { }) it("should fallback to default error response when onError hook returns void", async () => { - mockFetch.mockRejectedValue(new Error("Network error")) + fetchSpy.mockRejectedValue(new Error("Network error")) const onErrorHook = jest.fn().mockResolvedValue(undefined) @@ -147,7 +147,7 @@ describe("Proxy Fallback Response", () => { ] for (const testCase of testCases) { - mockFetch.mockRejectedValue(testCase.error) + fetchSpy.mockRejectedValue(testCase.error) const onErrorHook = jest.fn().mockResolvedValue( new Response(JSON.stringify({ message: testCase.fallbackMessage }), { @@ -179,7 +179,7 @@ describe("Proxy Fallback Response", () => { }) // First request fails to trigger circuit breaker - mockFetch.mockRejectedValue(new Error("Service error")) + fetchSpy.mockRejectedValue(new Error("Service error")) const onErrorHook = jest .fn() @@ -241,7 +241,7 @@ describe("Proxy Fallback Response", () => { it("should pass correct request and error objects to onError hook", async () => { const networkError = new Error("ECONNREFUSED") - mockFetch.mockRejectedValue(networkError) + fetchSpy.mockRejectedValue(networkError) const onErrorHook = jest .fn() @@ -269,7 +269,7 @@ describe("Proxy Fallback Response", () => { }) it("should handle multiple concurrent requests with fallback", async () => { - mockFetch.mockRejectedValue(new Error("Service unavailable")) + fetchSpy.mockRejectedValue(new Error("Service unavailable")) const onErrorHook = jest.fn().mockImplementation(async (req, error) => { return new Response( @@ -312,7 +312,7 @@ describe("Proxy Fallback Response", () => { }) it("should handle onError hook that throws an error", async () => { - mockFetch.mockRejectedValue(new Error("Network error")) + fetchSpy.mockRejectedValue(new Error("Network error")) const onErrorHook = jest.fn().mockImplementation(async () => { throw new Error("Hook error") @@ -333,7 +333,7 @@ describe("Proxy Fallback Response", () => { }) it("should handle fallback response with custom headers", async () => { - mockFetch.mockRejectedValue(new Error("Service error")) + fetchSpy.mockRejectedValue(new Error("Service error")) const onErrorHook = jest.fn().mockResolvedValue( new Response(JSON.stringify({ fallback: true }), { @@ -359,7 +359,7 @@ describe("Proxy Fallback Response", () => { }) it("should handle streaming fallback response", async () => { - mockFetch.mockRejectedValue(new Error("Streaming error")) + fetchSpy.mockRejectedValue(new Error("Streaming error")) const onErrorHook = jest.fn().mockImplementation(async (req, error) => { const stream = new ReadableStream({ @@ -397,7 +397,7 @@ describe("Proxy Fallback Response", () => { describe("Integration with Other Features", () => { it("should work with beforeRequest and afterResponse hooks", async () => { - mockFetch.mockRejectedValue(new Error("Network error")) + fetchSpy.mockRejectedValue(new Error("Network error")) const beforeRequestHook = jest.fn() const afterResponseHook = jest.fn() @@ -420,7 +420,7 @@ describe("Proxy Fallback Response", () => { }) it("should work with custom headers and query parameters", async () => { - mockFetch.mockRejectedValue(new Error("Network error")) + fetchSpy.mockRejectedValue(new Error("Network error")) const onErrorHook = jest .fn() From d47991223a34106821b3795d5c7d241e5ae684ae Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 13 Jul 2025 10:03:49 +0200 Subject: [PATCH 6/9] fix(proxy): update fallback response logging to indicate if a Response instance was provided --- src/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy.ts b/src/proxy.ts index 1d1721b..7e2e9d8 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -180,7 +180,7 @@ export class FetchProxy { state: this.circuitBreaker.getState(), failureCount: this.circuitBreaker.getFailures(), executionTimeMs: executionTime, - fallbackResponse, + fallbackResponseProvided: fallbackResponse instanceof Response, }, options, ) From 41ecb7971bb804fe5ff7fe4690778d7ff07f2910 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 13 Jul 2025 10:03:55 +0200 Subject: [PATCH 7/9] refactor(types): update CircuitBreakerResult to indicate if a fallback response was provided --- README.md | 1 + src/types.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d469d15..6fa7289 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ interface CircuitBreakerResult { state: CircuitState // Current circuit breaker state failureCount: number // Current failure count executionTimeMs: number // Execution time in milliseconds + fallbackResponseProvided?: boolean // Whether a fallback response was provided } ``` diff --git a/src/types.ts b/src/types.ts index 0dabf3b..7d24bd4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,7 @@ export interface CircuitBreakerResult { state: CircuitState failureCount: number executionTimeMs: number - fallbackResponse?: Response | void + fallbackResponseProvided?: boolean } export enum CircuitState { From d32dd152fc75cdc09d67efd0047ca14dd19c7ca1 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 13 Jul 2025 10:14:20 +0200 Subject: [PATCH 8/9] refactor(tests): replace mock with spy for improved test accuracy and remove unnecessary mock restores --- tests/dos-prevention.test.ts | 6 +----- tests/enhanced-hooks.test.ts | 26 +++++++++++++------------- tests/header-injection.test.ts | 6 +----- tests/http-method-validation.test.ts | 26 +++++++++++++++++++++----- tests/index.test.ts | 15 +++++++-------- tests/logging.test.ts | 16 ++++------------ tests/path-traversal.test.ts | 6 +----- tests/query-injection.test.ts | 6 +----- tests/security.test.ts | 6 +----- tests/utils.test.ts | 2 +- 10 files changed, 51 insertions(+), 64 deletions(-) diff --git a/tests/dos-prevention.test.ts b/tests/dos-prevention.test.ts index c5b1299..12e6423 100644 --- a/tests/dos-prevention.test.ts +++ b/tests/dos-prevention.test.ts @@ -1,8 +1,4 @@ -import { afterAll, describe, expect, test, mock } from "bun:test" - -afterAll(() => { - mock.restore() -}) +import { describe, expect, test } from "bun:test" describe("DoS and Resource Exhaustion Security Tests", () => { describe("Request Parameter Validation", () => { diff --git a/tests/enhanced-hooks.test.ts b/tests/enhanced-hooks.test.ts index 52d295b..f405cde 100644 --- a/tests/enhanced-hooks.test.ts +++ b/tests/enhanced-hooks.test.ts @@ -9,18 +9,17 @@ import { beforeEach, jest, afterAll, - mock, + spyOn, } from "bun:test" import { FetchProxy } from "../src/proxy" import { CircuitState } from "../src/types" import type { ProxyRequestOptions, CircuitBreakerResult } from "../src/types" -// Mock fetch for testing -const mockFetch = jest.fn() -;(global as any).fetch = mockFetch +// Spy on fetch for testing +let fetchSpy: ReturnType afterAll(() => { - mock.restore() + fetchSpy?.mockRestore() }) describe("Enhanced Hook Naming Conventions", () => { @@ -39,8 +38,9 @@ describe("Enhanced Hook Naming Conventions", () => { headers: new Headers({ "content-type": "application/json" }), }) - mockFetch.mockClear() - mockFetch.mockResolvedValue(mockResponse) + fetchSpy = spyOn(global, "fetch") + fetchSpy.mockClear() + fetchSpy.mockResolvedValue(mockResponse) }) describe("beforeRequest Hook", () => { @@ -56,7 +56,7 @@ describe("Enhanced Hook Naming Conventions", () => { expect(beforeRequestHook).toHaveBeenCalledTimes(1) expect(beforeRequestHook).toHaveBeenCalledWith(request, options) - expect(mockFetch).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledTimes(1) }) it("should handle async beforeRequest hooks", async () => { @@ -139,7 +139,7 @@ describe("Enhanced Hook Naming Conventions", () => { const request = new Request("https://example.com/test") const error = new Error("Network error") - mockFetch.mockRejectedValueOnce(error) + fetchSpy.mockRejectedValueOnce(error) const options: ProxyRequestOptions = { afterCircuitBreakerExecution: afterCircuitBreakerHook, @@ -166,7 +166,7 @@ describe("Enhanced Hook Naming Conventions", () => { const request = new Request("https://example.com/test") // Add some delay to the fetch - mockFetch.mockImplementationOnce( + fetchSpy.mockImplementationOnce( () => new Promise((resolve) => setTimeout(() => resolve(mockResponse), 50)), ) @@ -296,8 +296,8 @@ describe("Enhanced Hook Naming Conventions", () => { await proxy.proxy(request, undefined, options) // Verify the mock was called (we can't easily verify exact headers due to internal processing) - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(mockFetch).toHaveBeenCalledWith( + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.any(Headers), @@ -315,7 +315,7 @@ describe("Enhanced Hook Naming Conventions", () => { }, }) - mockFetch.mockResolvedValueOnce(originalResponse) + fetchSpy.mockResolvedValueOnce(originalResponse) const request = new Request("https://example.com/test") diff --git a/tests/header-injection.test.ts b/tests/header-injection.test.ts index 0c9e569..3475add 100644 --- a/tests/header-injection.test.ts +++ b/tests/header-injection.test.ts @@ -1,14 +1,10 @@ /** * Security tests for header injection vulnerabilities */ -import { describe, expect, it, afterAll, mock } from "bun:test" +import { describe, expect, it } from "bun:test" import { recordToHeaders } from "../src/utils" -afterAll(() => { - mock.restore() -}) - describe("Header Injection Security Tests", () => { describe("CRLF Header Injection", () => { it("should reject header names with CRLF characters", () => { diff --git a/tests/http-method-validation.test.ts b/tests/http-method-validation.test.ts index c24c981..c8dfb36 100644 --- a/tests/http-method-validation.test.ts +++ b/tests/http-method-validation.test.ts @@ -1,12 +1,13 @@ -import { describe, it, expect, beforeEach, afterAll, mock } from "bun:test" +import { describe, it, expect, beforeEach, spyOn, afterEach } from "bun:test" import { validateHttpMethod } from "../src/utils" import { FetchProxy } from "../src/proxy" -afterAll(() => { - mock.restore() -}) - describe("HTTP Method Validation Security Tests", () => { + let fetchSpy: ReturnType + + afterEach(() => { + fetchSpy?.mockRestore() + }) describe("Direct Method Validation", () => { it("should reject CONNECT method", () => { expect(() => { @@ -75,6 +76,15 @@ describe("HTTP Method Validation Security Tests", () => { base: "http://httpbin.org", // Use a real service for testing circuitBreaker: { enabled: false }, }) + + // Mock fetch to return a successful response + fetchSpy = spyOn(global, "fetch").mockResolvedValue( + new Response("", { + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "text/plain" }), + }) + ) }) it("should reject CONNECT method in proxy (if runtime allows it)", async () => { @@ -107,6 +117,9 @@ describe("HTTP Method Validation Security Tests", () => { // The normalized request should work fine const response = await proxy.proxy(request) expect(response.status).toBe(200) + + // Verify fetch was called + expect(fetchSpy).toHaveBeenCalledTimes(1) }) it("should allow safe methods in proxy", async () => { @@ -116,6 +129,9 @@ describe("HTTP Method Validation Security Tests", () => { const response = await proxy.proxy(request) expect(response.status).toBe(200) + + // Verify fetch was called + expect(fetchSpy).toHaveBeenCalledTimes(1) }) it("should validate methods when passed through request options", async () => { diff --git a/tests/index.test.ts b/tests/index.test.ts index f4a4564..e832a6a 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll, mock } from "bun:test" +import { describe, it, expect, beforeAll, afterAll, spyOn } from "bun:test" import createFetchGate, { FetchProxy } from "../src/index" import { buildURL, @@ -55,7 +55,7 @@ describe("fetch-gate", () => { afterAll(() => { server?.stop() - mock.restore() + // No need for explicit restore with spyOn as it's automatically cleaned up }) describe("createFetchGate", () => { @@ -308,10 +308,9 @@ describe("fetch-gate", () => { describe("Circuit Breaker Edge Cases", () => { it("should transition to HALF_OPEN state after reset timeout", async () => { - // Custom mock for Date.now() - const originalDateNow = Date.now - let now = originalDateNow() - global.Date.now = () => now + // Spy on Date.now() + let now = Date.now() + const dateNowSpy = spyOn(Date, "now").mockImplementation(() => now) const circuitBreaker = new CircuitBreaker({ failureThreshold: 1, @@ -330,8 +329,8 @@ describe("fetch-gate", () => { expect(circuitBreaker.getState()).toBe(CircuitState.HALF_OPEN) - // Restore original Date.now() - global.Date.now = originalDateNow + // Restore Date.now() spy + dateNowSpy.mockRestore() }) it("should reset failures after successful execution in HALF_OPEN state", async () => { diff --git a/tests/logging.test.ts b/tests/logging.test.ts index 91c0d25..103b7ca 100644 --- a/tests/logging.test.ts +++ b/tests/logging.test.ts @@ -1,12 +1,4 @@ -import { - describe, - expect, - it, - beforeEach, - spyOn, - afterAll, - mock, -} from "bun:test" +import { describe, expect, it, beforeEach, spyOn, afterAll } from "bun:test" import { FetchProxy } from "../src/proxy" import { ProxyLogger, @@ -15,11 +7,11 @@ import { } from "../src/logger" import { CircuitState } from "../src/types" -// Mock fetch for testing -const originalFetch = global.fetch +// Spy on fetch for testing +let fetchSpy: ReturnType afterAll(() => { - mock.restore() + fetchSpy?.mockRestore() }) describe("Logging Integration", () => { diff --git a/tests/path-traversal.test.ts b/tests/path-traversal.test.ts index c8d082b..5feeb17 100644 --- a/tests/path-traversal.test.ts +++ b/tests/path-traversal.test.ts @@ -1,10 +1,6 @@ -import { describe, expect, test, mock, afterAll } from "bun:test" +import { describe, expect, test } from "bun:test" import { normalizeSecurePath } from "../src/utils" -afterAll(() => { - mock.restore() -}) - describe("Path Traversal Security", () => { describe("normalizeSecurePath", () => { test("should normalize simple valid paths", () => { diff --git a/tests/query-injection.test.ts b/tests/query-injection.test.ts index 9602e6f..8dc36cb 100644 --- a/tests/query-injection.test.ts +++ b/tests/query-injection.test.ts @@ -1,11 +1,7 @@ -import { describe, it, expect, mock, afterAll } from "bun:test" +import { describe, it, expect } from "bun:test" import { buildQueryString } from "../src/utils" import { FetchProxy } from "../src/proxy" -afterAll(() => { - mock.restore() -}) - describe("Query String Injection Security Tests", () => { describe("Parameter Name Validation", () => { it("should handle parameter names with special characters safely", () => { diff --git a/tests/security.test.ts b/tests/security.test.ts index 53d3036..322ef95 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -1,10 +1,6 @@ -import { describe, it, expect, afterAll, mock } from "bun:test" +import { describe, it, expect } from "bun:test" import { buildURL } from "../src/utils" -afterAll(() => { - mock.restore() -}) - describe("Security Tests", () => { describe("SSRF Prevention", () => { it("should prevent file:// protocol access", () => { diff --git a/tests/utils.test.ts b/tests/utils.test.ts index b9d2e65..b943d84 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, mock, beforeEach } from "bun:test" +import { describe, it, expect, beforeEach } from "bun:test" import { buildURL, filterHeaders, From a04abd3f6daa1cff9ae09379e96e8449634ed879 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 13 Jul 2025 10:15:53 +0200 Subject: [PATCH 9/9] fix(tests): remove unnecessary whitespace in HTTP method validation tests --- tests/http-method-validation.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/http-method-validation.test.ts b/tests/http-method-validation.test.ts index c8dfb36..d75fc4e 100644 --- a/tests/http-method-validation.test.ts +++ b/tests/http-method-validation.test.ts @@ -83,7 +83,7 @@ describe("HTTP Method Validation Security Tests", () => { status: 200, statusText: "OK", headers: new Headers({ "content-type": "text/plain" }), - }) + }), ) }) @@ -117,7 +117,7 @@ describe("HTTP Method Validation Security Tests", () => { // The normalized request should work fine const response = await proxy.proxy(request) expect(response.status).toBe(200) - + // Verify fetch was called expect(fetchSpy).toHaveBeenCalledTimes(1) }) @@ -129,7 +129,7 @@ describe("HTTP Method Validation Security Tests", () => { const response = await proxy.proxy(request) expect(response.status).toBe(200) - + // Verify fetch was called expect(fetchSpy).toHaveBeenCalledTimes(1) })