Skip to content

Commit f7fa106

Browse files
authored
feat: add failover (#29)
* feat: add failover * refactor: rename variable * chore: add npmignore and increment version
1 parent 3c7823a commit f7fa106

7 files changed

Lines changed: 120 additions & 63 deletions

File tree

.npmignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
src
2+
.github
3+
packaging
4+
.dockerignore
5+
Dockerfile
6+
tsconfig.json

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ curl http://localhost:9000 -H "Content-Type: application/json" -X POST --data '{
2727
When the text `Cache hit` appears, it indicates that a request was saved and immediately returned:
2828

2929
```txt
30-
[~] Key: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
31-
[~] Cache hit: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
32-
[~] Cache hit: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
33-
[~] Cache hit: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
30+
Key: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
31+
Cache hit: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
32+
Cache hit: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
33+
Cache hit: {"jsonrpc":"2.0","method":"eth_blockNumber","params":[]}
3434
```
3535

3636
## Benefits

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "etherproxy",
3-
"version": "1.0.0",
3+
"version": "1.4.0",
44
"description": "JSON-RPC reverse proxy tool designed for caching requests",
55
"main": "dist/index.js",
66
"bin": {
@@ -15,7 +15,7 @@
1515
"author": "@Cafe137",
1616
"license": "MIT",
1717
"dependencies": {
18-
"cafe-utility": "^10.8.1",
18+
"cafe-utility": "^11.0.2",
1919
"node-fetch": "^2.6.9"
2020
},
2121
"devDependencies": {

src/index.ts

Lines changed: 84 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,70 +3,110 @@
33
import { Arrays, Objects } from 'cafe-utility'
44
import { IncomingMessage, ServerResponse, createServer } from 'http'
55
import fetch from 'node-fetch'
6+
import { Target, getHealthyTarget } from './target'
67
import { RequestContext, ResponseContext } from './types'
78
import { fetchWithTimeout, respondWithFetchPromise } from './utility'
89

10+
const PORT_ENV = 'ETHERPROXY_PORT'
11+
const EXPIRY_ENV = 'ETHERPROXY_EXPIRY'
12+
const TARGET_ENV = 'ETHERPROXY_TARGET'
13+
const DEFAULT_PORT = 9000
14+
const DEFAULT_EXPIRY = 2000
15+
const DEFAULT_TARGET = 'http://localhost:8545'
16+
917
main()
1018

1119
function main() {
12-
const port: number = parseInt(Arrays.getArgument(process.argv, 'port') as string) || parseInt((process.env.ETHERPROXY_PORT as string)) || 9000
13-
const target: string = Arrays.getArgument(process.argv, 'target') as string || process.env.ETHERPROXY_TARGET as string || "http://localhost:8545"
14-
const expiry: number = parseInt(Arrays.getArgument(process.argv, 'expiry') as string) || parseInt((process.env.ETHERPROXY_EXPIRY as string)) || 2000
15-
20+
const port = Arrays.getNumberArgument(process.argv, 'port', process.env, PORT_ENV) || DEFAULT_PORT
21+
const target = Arrays.getArgument(process.argv, 'target', process.env, TARGET_ENV) || DEFAULT_TARGET
22+
const expiry = Arrays.getNumberArgument(process.argv, 'expiry', process.env, EXPIRY_ENV) || DEFAULT_EXPIRY
1623
const fastIndex = Objects.createFastIndex()
24+
const targets: Target[] = target.split(',').map(x => ({
25+
url: x,
26+
lastErrorAt: 0
27+
}))
1728
const server = createServer(async (request: IncomingMessage, response: ServerResponse) => {
29+
request.on('error', error => {
30+
console.error(error)
31+
})
32+
response.on('error', error => {
33+
console.error(error)
34+
})
1835
if (request.url === '/health' || request.url === '/readiness') {
19-
try {
20-
await fetch(target, { timeout: 10_000 })
21-
response.statusCode = 200
22-
response.end('200 OK')
23-
} catch (error) {
24-
console.error(error)
25-
response.statusCode = 503
26-
response.end('503 Service Unavailable')
36+
for (let i = 0; i < targets.length; i++) {
37+
const target = getHealthyTarget(targets)
38+
try {
39+
await fetch(target.url, { timeout: 10_000 })
40+
response.statusCode = 200
41+
response.end('200 OK')
42+
return
43+
} catch (error) {
44+
target.lastErrorAt = Date.now()
45+
console.error(error)
46+
}
2747
}
48+
response.statusCode = 503
49+
response.end('503 Service Unavailable')
2850
return
2951
}
3052
const chunks: Buffer[] = []
3153
request.on('data', (chunk: Buffer) => {
3254
chunks.push(chunk)
3355
})
3456
request.on('end', async () => {
35-
try {
36-
const context: RequestContext = {
37-
method: request.method || 'GET',
38-
url: target,
39-
headers: request.headers as Record<string, string>,
40-
body: Buffer.concat(chunks).toString('utf-8')
41-
}
42-
const parsedBody = JSON.parse(context.body)
43-
const id = parsedBody.id
44-
delete parsedBody.id
45-
const key = JSON.stringify(parsedBody)
46-
const cachedPromise = Objects.getFromFastIndexWithExpiracy(fastIndex, key) as Promise<ResponseContext>
47-
if (cachedPromise) {
48-
process.stdout.write(`[~] Cache hit: ${key}\n`)
49-
await respondWithFetchPromise(id, response, cachedPromise)
50-
return
57+
for (let i = 0; i < targets.length; i++) {
58+
const target = getHealthyTarget(targets)
59+
try {
60+
const context: RequestContext = {
61+
method: request.method || 'GET',
62+
url: target.url,
63+
headers: request.headers as Record<string, string>,
64+
body: Buffer.concat(chunks).toString('utf-8')
65+
}
66+
const parsedBody = JSON.parse(context.body)
67+
const id = parsedBody.id
68+
delete parsedBody.id
69+
const key = `${target.url}_${JSON.stringify(parsedBody)}`
70+
const cachedPromise = Objects.getFromFastIndexWithExpiracy(
71+
fastIndex,
72+
key
73+
) as Promise<ResponseContext>
74+
if (cachedPromise) {
75+
process.stdout.write(`Cache hit: ${key}\n`)
76+
const successful = await respondWithFetchPromise(id, response, cachedPromise)
77+
if (successful) {
78+
return
79+
} else {
80+
target.lastErrorAt = Date.now()
81+
continue
82+
}
83+
}
84+
process.stdout.write(`Key: ${key}\n`)
85+
delete context.headers.host
86+
delete context.headers['user-agent']
87+
delete context.headers['content-length']
88+
const responsePromise = fetchWithTimeout(context.url, {
89+
method: context.method,
90+
headers: context.headers,
91+
body: context.body
92+
})
93+
Objects.pushToFastIndexWithExpiracy(fastIndex as any, key, responsePromise, expiry)
94+
const successful = await respondWithFetchPromise(id, response, responsePromise)
95+
if (successful) {
96+
return
97+
} else {
98+
target.lastErrorAt = Date.now()
99+
continue
100+
}
101+
} catch (error) {
102+
target.lastErrorAt = Date.now()
103+
console.error(error)
51104
}
52-
process.stdout.write(`[~] Key: ${key}\n`)
53-
delete context.headers.host
54-
delete context.headers['user-agent']
55-
delete context.headers['content-length']
56-
const responsePromise = fetchWithTimeout(context.url, {
57-
method: context.method,
58-
headers: context.headers,
59-
body: context.body
60-
})
61-
Objects.pushToFastIndexWithExpiracy(fastIndex as any, key, responsePromise, expiry)
62-
await respondWithFetchPromise(id, response, responsePromise)
63-
} catch (error) {
64-
console.error(error)
65-
response.statusCode = 503
66-
response.end('503 Service Unavailable')
67105
}
106+
response.statusCode = 503
107+
response.end('503 Service Unavailable')
68108
})
69109
})
70110
server.listen(port)
71-
console.log(`[~] Etherproxy is running on port ${port}`)
111+
console.log(`Etherproxy is running on port ${port}`)
72112
}

src/target.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Dates } from 'cafe-utility'
2+
3+
export type Target = {
4+
url: string
5+
lastErrorAt: number
6+
}
7+
8+
export function getHealthyTarget(targets: Target[]): Target {
9+
const healthyIfLastErrorIsBefore = Date.now() - Dates.hours(2)
10+
const healthyTargets = targets.filter(x => x.lastErrorAt < healthyIfLastErrorIsBefore)
11+
return healthyTargets[0] || targets[0]
12+
}

src/utility.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,10 @@ export async function respondWithFetchPromise(
66
id: number,
77
response: ServerResponse,
88
promise: Promise<ResponseContext | null>
9-
) {
9+
): Promise<boolean> {
1010
const context = await promise
1111
if (!context) {
12-
response.statusCode = 503
13-
response.end('503 Service Unavailable')
14-
return
12+
return false
1513
}
1614
for (const [key, value] of Object.entries(context.headers)) {
1715
const lowerCaseKey = key.toLowerCase()
@@ -23,6 +21,7 @@ export async function respondWithFetchPromise(
2321
response.statusCode = context.status
2422
context.json.id = id
2523
response.end(JSON.stringify(context.json))
24+
return true
2625
}
2726

2827
export async function fetchWithTimeout(url: string, options: RequestInit): Promise<ResponseContext | null> {

0 commit comments

Comments
 (0)