Skip to content

Commit e7ca873

Browse files
authored
Merge pull request #2 from BackendStack21/add-fallback-support-via-onError-hook
Add fallback support via on error hook
2 parents 8da4887 + a04abd3 commit e7ca873

15 files changed

+534
-69
lines changed

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ interface CircuitBreakerResult {
135135
state: CircuitState // Current circuit breaker state
136136
failureCount: number // Current failure count
137137
executionTimeMs: number // Execution time in milliseconds
138+
fallbackResponseProvided?: boolean // Whether a fallback response was provided
138139
}
139140
```
140141

@@ -179,7 +180,10 @@ interface ProxyRequestOptions {
179180
res: Response,
180181
body?: ReadableStream | null,
181182
) => void | Promise<void>
182-
onError?: (req: Request, error: Error) => void | Promise<void>
183+
onError?: (
184+
req: Request,
185+
error: Error,
186+
) => void | Promise<void> | Promise<Response>
183187
beforeCircuitBreakerExecution?: (
184188
req: Request,
185189
opts: ProxyRequestOptions,
@@ -547,6 +551,23 @@ proxy(req, undefined, {
547551
})
548552
```
549553

554+
#### Returning Fallback Responses
555+
556+
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.
557+
558+
```typescript
559+
proxy(req, undefined, {
560+
onError: async (req, error) => {
561+
// Log error
562+
console.error("Proxy error:", error)
563+
564+
// Return a fallback response
565+
console.log("Returning fallback response for:", req.url)
566+
return new Response("Fallback response", { status: 200 })
567+
},
568+
})
569+
```
570+
550571
## Performance Tips
551572

552573
1. **URL Caching**: Keep `cacheURLs` enabled (default 100) for better performance

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"clean": "rm -rf lib/",
2727
"prepublishOnly": "bun run clean && bun run build",
2828
"example:benchmark": "bun run examples/local-gateway-server.ts",
29-
"deploy": "bun run prepublishOnly && bun publish"
29+
"deploy": "bun run prepublishOnly && bun publish",
30+
"actions": "DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}') act"
3031
},
3132
"repository": {
3233
"type": "git",

src/proxy.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,9 @@ export class FetchProxy {
166166
currentLogger.logRequestError(req, err, { requestId, executionTime })
167167

168168
// Execute error hooks
169+
let fallbackResponse: Response | void = undefined
169170
if (options.onError) {
170-
await options.onError(req, err)
171+
fallbackResponse = await options.onError(req, err)
171172
}
172173

173174
// Execute circuit breaker completion hooks for failures
@@ -179,12 +180,17 @@ export class FetchProxy {
179180
state: this.circuitBreaker.getState(),
180181
failureCount: this.circuitBreaker.getFailures(),
181182
executionTimeMs: executionTime,
183+
fallbackResponseProvided: fallbackResponse instanceof Response,
182184
},
183185
options,
184186
)
185187

188+
if (fallbackResponse instanceof Response) {
189+
// If onError provided a fallback response, return it
190+
return fallbackResponse
191+
}
186192
// Return appropriate error response
187-
if (err.message.includes("Circuit breaker is OPEN")) {
193+
else if (err.message.includes("Circuit breaker is OPEN")) {
188194
return new Response("Service Unavailable", { status: 503 })
189195
} else if (
190196
err.message.includes("timeout") ||

src/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ export type AfterCircuitBreakerHook = (
8383
result: CircuitBreakerResult,
8484
) => void | Promise<void>
8585

86-
export type ErrorHook = (req: Request, error: Error) => void | Promise<void>
86+
export type ErrorHook = (
87+
req: Request,
88+
error: Error,
89+
) => void | Promise<void> | Promise<Response>
8790

8891
// Circuit breaker result information
8992
export interface CircuitBreakerResult {
@@ -92,6 +95,7 @@ export interface CircuitBreakerResult {
9295
state: CircuitState
9396
failureCount: number
9497
executionTimeMs: number
98+
fallbackResponseProvided?: boolean
9599
}
96100

97101
export enum CircuitState {

tests/dos-prevention.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import { afterAll, describe, expect, test, mock } from "bun:test"
2-
3-
afterAll(() => {
4-
mock.restore()
5-
})
1+
import { describe, expect, test } from "bun:test"
62

73
describe("DoS and Resource Exhaustion Security Tests", () => {
84
describe("Request Parameter Validation", () => {

tests/enhanced-hooks.test.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,17 @@ import {
99
beforeEach,
1010
jest,
1111
afterAll,
12-
mock,
12+
spyOn,
1313
} from "bun:test"
1414
import { FetchProxy } from "../src/proxy"
1515
import { CircuitState } from "../src/types"
1616
import type { ProxyRequestOptions, CircuitBreakerResult } from "../src/types"
1717

18-
// Mock fetch for testing
19-
const mockFetch = jest.fn()
20-
;(global as any).fetch = mockFetch
18+
// Spy on fetch for testing
19+
let fetchSpy: ReturnType<typeof spyOn>
2120

2221
afterAll(() => {
23-
mock.restore()
22+
fetchSpy?.mockRestore()
2423
})
2524

2625
describe("Enhanced Hook Naming Conventions", () => {
@@ -39,8 +38,9 @@ describe("Enhanced Hook Naming Conventions", () => {
3938
headers: new Headers({ "content-type": "application/json" }),
4039
})
4140

42-
mockFetch.mockClear()
43-
mockFetch.mockResolvedValue(mockResponse)
41+
fetchSpy = spyOn(global, "fetch")
42+
fetchSpy.mockClear()
43+
fetchSpy.mockResolvedValue(mockResponse)
4444
})
4545

4646
describe("beforeRequest Hook", () => {
@@ -56,7 +56,7 @@ describe("Enhanced Hook Naming Conventions", () => {
5656

5757
expect(beforeRequestHook).toHaveBeenCalledTimes(1)
5858
expect(beforeRequestHook).toHaveBeenCalledWith(request, options)
59-
expect(mockFetch).toHaveBeenCalledTimes(1)
59+
expect(fetchSpy).toHaveBeenCalledTimes(1)
6060
})
6161

6262
it("should handle async beforeRequest hooks", async () => {
@@ -139,7 +139,7 @@ describe("Enhanced Hook Naming Conventions", () => {
139139
const request = new Request("https://example.com/test")
140140
const error = new Error("Network error")
141141

142-
mockFetch.mockRejectedValueOnce(error)
142+
fetchSpy.mockRejectedValueOnce(error)
143143

144144
const options: ProxyRequestOptions = {
145145
afterCircuitBreakerExecution: afterCircuitBreakerHook,
@@ -166,7 +166,7 @@ describe("Enhanced Hook Naming Conventions", () => {
166166
const request = new Request("https://example.com/test")
167167

168168
// Add some delay to the fetch
169-
mockFetch.mockImplementationOnce(
169+
fetchSpy.mockImplementationOnce(
170170
() =>
171171
new Promise((resolve) => setTimeout(() => resolve(mockResponse), 50)),
172172
)
@@ -296,8 +296,8 @@ describe("Enhanced Hook Naming Conventions", () => {
296296
await proxy.proxy(request, undefined, options)
297297

298298
// Verify the mock was called (we can't easily verify exact headers due to internal processing)
299-
expect(mockFetch).toHaveBeenCalledTimes(1)
300-
expect(mockFetch).toHaveBeenCalledWith(
299+
expect(fetchSpy).toHaveBeenCalledTimes(1)
300+
expect(fetchSpy).toHaveBeenCalledWith(
301301
expect.any(String),
302302
expect.objectContaining({
303303
headers: expect.any(Headers),
@@ -315,7 +315,7 @@ describe("Enhanced Hook Naming Conventions", () => {
315315
},
316316
})
317317

318-
mockFetch.mockResolvedValueOnce(originalResponse)
318+
fetchSpy.mockResolvedValueOnce(originalResponse)
319319

320320
const request = new Request("https://example.com/test")
321321

tests/header-injection.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
/**
22
* Security tests for header injection vulnerabilities
33
*/
4-
import { describe, expect, it, afterAll, mock } from "bun:test"
4+
import { describe, expect, it } from "bun:test"
55

66
import { recordToHeaders } from "../src/utils"
77

8-
afterAll(() => {
9-
mock.restore()
10-
})
11-
128
describe("Header Injection Security Tests", () => {
139
describe("CRLF Header Injection", () => {
1410
it("should reject header names with CRLF characters", () => {

tests/http-method-validation.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { describe, it, expect, beforeEach, afterAll, mock } from "bun:test"
1+
import { describe, it, expect, beforeEach, spyOn, afterEach } from "bun:test"
22
import { validateHttpMethod } from "../src/utils"
33
import { FetchProxy } from "../src/proxy"
44

5-
afterAll(() => {
6-
mock.restore()
7-
})
8-
95
describe("HTTP Method Validation Security Tests", () => {
6+
let fetchSpy: ReturnType<typeof spyOn>
7+
8+
afterEach(() => {
9+
fetchSpy?.mockRestore()
10+
})
1011
describe("Direct Method Validation", () => {
1112
it("should reject CONNECT method", () => {
1213
expect(() => {
@@ -75,6 +76,15 @@ describe("HTTP Method Validation Security Tests", () => {
7576
base: "http://httpbin.org", // Use a real service for testing
7677
circuitBreaker: { enabled: false },
7778
})
79+
80+
// Mock fetch to return a successful response
81+
fetchSpy = spyOn(global, "fetch").mockResolvedValue(
82+
new Response("", {
83+
status: 200,
84+
statusText: "OK",
85+
headers: new Headers({ "content-type": "text/plain" }),
86+
}),
87+
)
7888
})
7989

8090
it("should reject CONNECT method in proxy (if runtime allows it)", async () => {
@@ -107,6 +117,9 @@ describe("HTTP Method Validation Security Tests", () => {
107117
// The normalized request should work fine
108118
const response = await proxy.proxy(request)
109119
expect(response.status).toBe(200)
120+
121+
// Verify fetch was called
122+
expect(fetchSpy).toHaveBeenCalledTimes(1)
110123
})
111124

112125
it("should allow safe methods in proxy", async () => {
@@ -116,6 +129,9 @@ describe("HTTP Method Validation Security Tests", () => {
116129

117130
const response = await proxy.proxy(request)
118131
expect(response.status).toBe(200)
132+
133+
// Verify fetch was called
134+
expect(fetchSpy).toHaveBeenCalledTimes(1)
119135
})
120136

121137
it("should validate methods when passed through request options", async () => {

tests/index.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeAll, afterAll, mock } from "bun:test"
1+
import { describe, it, expect, beforeAll, afterAll, spyOn } from "bun:test"
22
import createFetchGate, { FetchProxy } from "../src/index"
33
import {
44
buildURL,
@@ -55,7 +55,7 @@ describe("fetch-gate", () => {
5555

5656
afterAll(() => {
5757
server?.stop()
58-
mock.restore()
58+
// No need for explicit restore with spyOn as it's automatically cleaned up
5959
})
6060

6161
describe("createFetchGate", () => {
@@ -308,10 +308,9 @@ describe("fetch-gate", () => {
308308

309309
describe("Circuit Breaker Edge Cases", () => {
310310
it("should transition to HALF_OPEN state after reset timeout", async () => {
311-
// Custom mock for Date.now()
312-
const originalDateNow = Date.now
313-
let now = originalDateNow()
314-
global.Date.now = () => now
311+
// Spy on Date.now()
312+
let now = Date.now()
313+
const dateNowSpy = spyOn(Date, "now").mockImplementation(() => now)
315314

316315
const circuitBreaker = new CircuitBreaker({
317316
failureThreshold: 1,
@@ -330,8 +329,8 @@ describe("fetch-gate", () => {
330329

331330
expect(circuitBreaker.getState()).toBe(CircuitState.HALF_OPEN)
332331

333-
// Restore original Date.now()
334-
global.Date.now = originalDateNow
332+
// Restore Date.now() spy
333+
dateNowSpy.mockRestore()
335334
})
336335

337336
it("should reset failures after successful execution in HALF_OPEN state", async () => {

tests/logging.test.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
1-
import {
2-
describe,
3-
expect,
4-
it,
5-
beforeEach,
6-
spyOn,
7-
afterAll,
8-
mock,
9-
} from "bun:test"
1+
import { describe, expect, it, beforeEach, spyOn, afterAll } from "bun:test"
102
import { FetchProxy } from "../src/proxy"
113
import {
124
ProxyLogger,
@@ -15,11 +7,11 @@ import {
157
} from "../src/logger"
168
import { CircuitState } from "../src/types"
179

18-
// Mock fetch for testing
19-
const originalFetch = global.fetch
10+
// Spy on fetch for testing
11+
let fetchSpy: ReturnType<typeof spyOn>
2012

2113
afterAll(() => {
22-
mock.restore()
14+
fetchSpy?.mockRestore()
2315
})
2416

2517
describe("Logging Integration", () => {

0 commit comments

Comments
 (0)