-
Notifications
You must be signed in to change notification settings - Fork 112
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: health checks for worker and fetcher
- Loading branch information
1 parent
20d2444
commit 6824c13
Showing
16 changed files
with
323 additions
and
79 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,12 @@ | ||
import { Module } from "@nestjs/common"; | ||
import { TerminusModule } from "@nestjs/terminus"; | ||
import { HttpModule } from "@nestjs/axios"; | ||
import { HealthController } from "./health.controller"; | ||
import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; | ||
|
||
@Module({ | ||
controllers: [HealthController], | ||
imports: [TerminusModule], | ||
imports: [TerminusModule, HttpModule], | ||
providers: [JsonRpcHealthIndicator], | ||
}) | ||
export class HealthModule {} |
105 changes: 80 additions & 25 deletions
105
packages/data-fetcher/src/health/jsonRpcProvider.health.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,56 +1,111 @@ | ||
import { Test, TestingModule } from "@nestjs/testing"; | ||
import { Logger } from "@nestjs/common"; | ||
import { mock } from "jest-mock-extended"; | ||
import { HealthCheckError } from "@nestjs/terminus"; | ||
import { JsonRpcProviderBase } from "../rpcProvider"; | ||
import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; | ||
import { ConfigService } from "@nestjs/config"; | ||
import { HttpService } from "@nestjs/axios"; | ||
import { of, throwError } from "rxjs"; | ||
import { AxiosError } from "axios"; | ||
|
||
describe("JsonRpcHealthIndicator", () => { | ||
const healthIndicatorKey = "rpcProvider"; | ||
let jsonRpcProviderMock: JsonRpcProviderBase; | ||
let jsonRpcHealthIndicator: JsonRpcHealthIndicator; | ||
let httpService: HttpService; | ||
let configService: ConfigService; | ||
|
||
beforeEach(async () => { | ||
jsonRpcProviderMock = mock<JsonRpcProviderBase>(); | ||
|
||
const getHealthIndicator = async () => { | ||
const app: TestingModule = await Test.createTestingModule({ | ||
providers: [ | ||
JsonRpcHealthIndicator, | ||
{ | ||
provide: JsonRpcProviderBase, | ||
useValue: jsonRpcProviderMock, | ||
}, | ||
{ | ||
provide: HttpService, | ||
useValue: httpService, | ||
}, | ||
{ | ||
provide: ConfigService, | ||
useValue: configService, | ||
}, | ||
], | ||
}).compile(); | ||
|
||
jsonRpcHealthIndicator = app.get<JsonRpcHealthIndicator>(JsonRpcHealthIndicator); | ||
app.useLogger(mock<Logger>()); | ||
return app.get<JsonRpcHealthIndicator>(JsonRpcHealthIndicator); | ||
}; | ||
|
||
beforeEach(async () => { | ||
jsonRpcProviderMock = mock<JsonRpcProviderBase>(); | ||
|
||
httpService = mock<HttpService>({ | ||
post: jest.fn(), | ||
}); | ||
|
||
configService = mock<ConfigService>({ | ||
get: jest.fn().mockImplementation((key: string) => { | ||
if (key === "blockchain.rpcUrl") return "http://localhost:3050"; | ||
if (key === "healthChecks.rpcHealthCheckTimeoutMs") return 5000; | ||
return null; | ||
}), | ||
}); | ||
|
||
jsonRpcHealthIndicator = await getHealthIndicator(); | ||
}); | ||
|
||
describe("isHealthy", () => { | ||
describe("when rpcProvider is open", () => { | ||
beforeEach(() => { | ||
jest.spyOn(jsonRpcProviderMock, "getState").mockReturnValueOnce("open"); | ||
}); | ||
const rpcRequest = { | ||
id: 1, | ||
jsonrpc: "2.0", | ||
method: "eth_chainId", | ||
params: [], | ||
}; | ||
|
||
it("returns OK health indicator result", async () => { | ||
const result = await jsonRpcHealthIndicator.isHealthy(healthIndicatorKey); | ||
expect(result).toEqual({ [healthIndicatorKey]: { rpcProviderState: "open", status: "up" } }); | ||
it("returns healthy status when RPC responds successfully", async () => { | ||
(httpService.post as jest.Mock).mockReturnValueOnce(of({ data: { result: "0x1" } })); | ||
const result = await jsonRpcHealthIndicator.isHealthy("jsonRpcProvider"); | ||
expect(result).toEqual({ | ||
jsonRpcProvider: { | ||
status: "up", | ||
}, | ||
}); | ||
expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); | ||
}); | ||
|
||
describe("when rpcProvider is closed", () => { | ||
beforeEach(() => { | ||
jest.spyOn(jsonRpcProviderMock, "getState").mockReturnValueOnce("closed"); | ||
}); | ||
it("throws HealthCheckError when RPC request fails", async () => { | ||
const error = new AxiosError(); | ||
error.response = { | ||
status: 503, | ||
data: "Service Unavailable", | ||
} as any; | ||
|
||
it("throws HealthCheckError error", async () => { | ||
expect.assertions(2); | ||
try { | ||
await jsonRpcHealthIndicator.isHealthy(healthIndicatorKey); | ||
} catch (error) { | ||
expect(error).toBeInstanceOf(HealthCheckError); | ||
expect(error.message).toBe("JSON RPC provider is not in open state"); | ||
} | ||
(httpService.post as jest.Mock).mockReturnValueOnce(throwError(() => error)); | ||
await expect(jsonRpcHealthIndicator.isHealthy("jsonRpcProvider")).rejects.toThrow(); | ||
expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); | ||
}); | ||
|
||
it("throws HealthCheckError when RPC request times out", async () => { | ||
const error = new AxiosError(); | ||
error.code = "ECONNABORTED"; | ||
|
||
(httpService.post as jest.Mock).mockReturnValueOnce(throwError(() => error)); | ||
await expect(jsonRpcHealthIndicator.isHealthy("jsonRpcProvider")).rejects.toThrow(); | ||
expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); | ||
}); | ||
|
||
it("should use configured timeout from config service", async () => { | ||
(configService.get as jest.Mock).mockImplementation((key: string) => { | ||
if (key === "blockchain.rpcUrl") return "http://localhost:3050"; | ||
if (key === "healthChecks.rpcHealthCheckTimeoutMs") return 10000; | ||
return null; | ||
}); | ||
jsonRpcHealthIndicator = await getHealthIndicator(); | ||
|
||
(httpService.post as jest.Mock).mockReturnValueOnce(of({ data: { result: "0x1" } })); | ||
await jsonRpcHealthIndicator.isHealthy("jsonRpcProvider"); | ||
expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 10000 }); | ||
}); | ||
}); | ||
}); |
54 changes: 48 additions & 6 deletions
54
packages/data-fetcher/src/health/jsonRpcProvider.health.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,22 +1,64 @@ | ||
import { Injectable } from "@nestjs/common"; | ||
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from "@nestjs/terminus"; | ||
import { JsonRpcProviderBase } from "../rpcProvider"; | ||
import { ConfigService } from "@nestjs/config"; | ||
import { Logger } from "@nestjs/common"; | ||
import { HttpService } from "@nestjs/axios"; | ||
import { catchError, firstValueFrom, of } from "rxjs"; | ||
import { AxiosError } from "axios"; | ||
|
||
@Injectable() | ||
export class JsonRpcHealthIndicator extends HealthIndicator { | ||
constructor(private readonly provider: JsonRpcProviderBase) { | ||
private readonly rpcUrl: string; | ||
private readonly healthCheckTimeoutMs: number; | ||
private readonly logger: Logger; | ||
|
||
constructor(configService: ConfigService, private readonly httpService: HttpService) { | ||
super(); | ||
this.logger = new Logger(JsonRpcHealthIndicator.name); | ||
this.rpcUrl = configService.get<string>("blockchain.rpcUrl"); | ||
this.healthCheckTimeoutMs = configService.get<number>("healthChecks.rpcHealthCheckTimeoutMs"); | ||
} | ||
|
||
async isHealthy(key: string): Promise<HealthIndicatorResult> { | ||
const rpcProviderState = this.provider.getState(); | ||
const isHealthy = rpcProviderState === "open"; | ||
const result = this.getStatus(key, isHealthy, { rpcProviderState }); | ||
let isHealthy = true; | ||
try { | ||
// Check RPC health with a pure HTTP request to remove SDK out of the picture | ||
// and avoid any SDK-specific issues. | ||
// Use eth_chainId call as it is the lightest one and return a static value from the memory. | ||
await firstValueFrom( | ||
this.httpService | ||
.post( | ||
this.rpcUrl, | ||
{ | ||
id: 1, | ||
jsonrpc: "2.0", | ||
method: "eth_chainId", | ||
params: [], | ||
}, | ||
{ timeout: this.healthCheckTimeoutMs } | ||
) | ||
.pipe( | ||
catchError((error: AxiosError) => { | ||
this.logger.error({ | ||
message: `Failed to ping RPC`, | ||
stack: error.stack, | ||
status: error.response?.status, | ||
response: error.response?.data, | ||
}); | ||
throw error; | ||
}) | ||
) | ||
); | ||
} catch { | ||
isHealthy = false; | ||
} | ||
|
||
const result = this.getStatus(key, isHealthy, { status: isHealthy ? "up" : "down" }); | ||
|
||
if (isHealthy) { | ||
return result; | ||
} | ||
|
||
throw new HealthCheckError("JSON RPC provider is not in open state", result); | ||
throw new HealthCheckError("JSON RPC provider is down or not reachable", result); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.