-
Notifications
You must be signed in to change notification settings - Fork 6
Fallback of RPC selection #692
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3af251c
fb465d4
d99efc0
4b4f22f
a7e0116
18b8b7b
f2e053a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,11 @@ | ||
| # Changelog | ||
|
|
||
| ## 2.0.21 | ||
|
|
||
| ### Patch Changes | ||
|
|
||
| - Fallback of RPC selection (#692) | ||
|
|
||
| ## 2.0.20 | ||
|
|
||
| ### Patch Changes | ||
|
|
||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from "./requests"; | ||
| export * from "./verify-on-alternate-node"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { CONFIG } from "@/modules/core"; | ||
|
|
||
| // Must use vi.hoisted for variables referenced in vi.mock factories | ||
| const mockCall = vi.hoisted(() => vi.fn()); | ||
| const mockClientConstructor = vi.hoisted(() => | ||
| vi.fn().mockImplementation(() => ({ call: mockCall })) | ||
| ); | ||
|
|
||
| vi.mock("@hiveio/dhive", () => ({ | ||
| Client: mockClientConstructor, | ||
| })); | ||
|
|
||
| vi.mock("@/modules/core", async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import("@/modules/core")>(); | ||
| return { | ||
| ...actual, | ||
| CONFIG: { | ||
| hiveClient: { | ||
| address: [ | ||
| "https://api.hive.blog", | ||
| "https://api.deathwing.me", | ||
| "https://api.openhive.network", | ||
| ], | ||
| currentAddress: "https://api.hive.blog", | ||
| }, | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| // Import after mocks are set up | ||
| import { verifyPostOnAlternateNode, MAX_ALTERNATE_NODES } from "./verify-on-alternate-node"; | ||
|
|
||
| describe("verifyPostOnAlternateNode", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| // Reset node config | ||
| (CONFIG.hiveClient as any).address = [ | ||
| "https://api.hive.blog", | ||
| "https://api.deathwing.me", | ||
| "https://api.openhive.network", | ||
| ]; | ||
| (CONFIG.hiveClient as any).currentAddress = "https://api.hive.blog"; | ||
| }); | ||
|
|
||
| it("should return null when node list has fewer than 2 nodes", async () => { | ||
| (CONFIG.hiveClient as any).address = ["https://api.hive.blog"]; | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog"); | ||
| expect(result).toBeNull(); | ||
| expect(mockClientConstructor).not.toHaveBeenCalled(); | ||
| expect(mockCall).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("should skip the primary node snapshot and try alternates", async () => { | ||
| const mockEntry = { author: "author", permlink: "permlink", post_id: 123 }; | ||
| mockCall.mockResolvedValueOnce(mockEntry); | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog"); | ||
|
|
||
| expect(result).toEqual(mockEntry); | ||
| expect(mockClientConstructor).toHaveBeenCalledTimes(1); | ||
| expect(mockClientConstructor).toHaveBeenCalledWith( | ||
| "https://api.deathwing.me", | ||
| expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| expect(mockCall).toHaveBeenCalledTimes(1); | ||
| expect(mockCall).toHaveBeenCalledWith("bridge", "get_post", { | ||
| author: "author", | ||
| permlink: "permlink", | ||
| observer: "", | ||
| }); | ||
| }); | ||
|
|
||
| it("should return null when all alternate nodes return null", async () => { | ||
| mockCall.mockResolvedValue(null); | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", "obs", "https://api.hive.blog"); | ||
|
|
||
| expect(result).toBeNull(); | ||
| expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES); | ||
| expect(mockClientConstructor).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES); | ||
| // Verify each alternate was tried with correct URL | ||
| expect(mockClientConstructor).toHaveBeenNthCalledWith( | ||
| 1, "https://api.deathwing.me", expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| expect(mockClientConstructor).toHaveBeenNthCalledWith( | ||
| 2, "https://api.openhive.network", expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| }); | ||
|
|
||
| it("should try next node when first alternate throws", async () => { | ||
| const mockEntry = { author: "author", permlink: "permlink", post_id: 456 }; | ||
| mockCall.mockRejectedValueOnce(new Error("timeout")).mockResolvedValueOnce(mockEntry); | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog"); | ||
|
|
||
| expect(result).toEqual(mockEntry); | ||
| expect(mockCall).toHaveBeenCalledTimes(2); | ||
| expect(mockClientConstructor).toHaveBeenNthCalledWith( | ||
| 1, "https://api.deathwing.me", expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| expect(mockClientConstructor).toHaveBeenNthCalledWith( | ||
| 2, "https://api.openhive.network", expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| }); | ||
|
|
||
| it("should return null when all alternates throw", async () => { | ||
| mockCall.mockRejectedValue(new Error("network error")); | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog"); | ||
|
|
||
| expect(result).toBeNull(); | ||
| expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES); | ||
| }); | ||
|
|
||
| it("should try at most MAX_ALTERNATE_NODES alternate nodes", async () => { | ||
| (CONFIG.hiveClient as any).address = [ | ||
| "https://node1.com", | ||
| "https://node2.com", | ||
| "https://node3.com", | ||
| "https://node4.com", | ||
| "https://node5.com", | ||
| ]; | ||
| mockCall.mockResolvedValue(null); | ||
|
|
||
| await verifyPostOnAlternateNode("author", "permlink", "", "https://node1.com"); | ||
|
|
||
| expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES); | ||
| expect(mockClientConstructor).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES); | ||
| }); | ||
|
|
||
| it("should fall back to currentAddress when no primaryNode provided", async () => { | ||
| mockCall.mockResolvedValue(null); | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", ""); | ||
|
|
||
| expect(result).toBeNull(); | ||
| // Falls back to CONFIG.hiveClient.currentAddress (hive.blog), excludes it by identity | ||
| expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES); | ||
| expect(mockClientConstructor).toHaveBeenNthCalledWith( | ||
| 1, "https://api.deathwing.me", expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| expect(mockClientConstructor).toHaveBeenNthCalledWith( | ||
| 2, "https://api.openhive.network", expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| }); | ||
|
|
||
| it("should use primaryNode snapshot to exclude the correct node even if currentAddress changed", async () => { | ||
| // Simulate: primary was hive.blog, but failover moved currentAddress to deathwing | ||
| (CONFIG.hiveClient as any).currentAddress = "https://api.deathwing.me"; | ||
| const mockEntry = { author: "author", permlink: "permlink", post_id: 789 }; | ||
| mockCall.mockResolvedValueOnce(mockEntry); | ||
|
|
||
| // Pass the snapshot of hive.blog as primaryNode | ||
| const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog"); | ||
|
|
||
| expect(result).toEqual(mockEntry); | ||
| // Should exclude hive.blog (the snapshot), not deathwing (the current) | ||
| expect(mockClientConstructor).toHaveBeenCalledTimes(1); | ||
| expect(mockClientConstructor).toHaveBeenCalledWith( | ||
| "https://api.deathwing.me", | ||
| expect.objectContaining({ timeout: 10000 }) | ||
| ); | ||
| }); | ||
|
|
||
| it("should reject response with mismatched author/permlink", async () => { | ||
| // Node returns a valid-looking entry but for a different post | ||
| const wrongEntry = { author: "other-author", permlink: "other-permlink", post_id: 999 }; | ||
| mockCall.mockResolvedValue(wrongEntry); | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog"); | ||
|
|
||
| expect(result).toBeNull(); | ||
| // Tried both alternates, both returned mismatched data | ||
| expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES); | ||
| }); | ||
|
|
||
| it("should accept response only when author and permlink match", async () => { | ||
| // First node returns mismatched, second returns correct | ||
| const wrongEntry = { author: "wrong", permlink: "permlink", post_id: 100 }; | ||
| const correctEntry = { author: "author", permlink: "permlink", post_id: 200 }; | ||
| mockCall.mockResolvedValueOnce(wrongEntry).mockResolvedValueOnce(correctEntry); | ||
|
|
||
| const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog"); | ||
|
|
||
| expect(result).toEqual(correctEntry); | ||
| expect(mockCall).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { Client } from "@hiveio/dhive"; | ||
| import { CONFIG } from "@/modules/core"; | ||
| import { Entry } from "@/modules/posts/types"; | ||
|
|
||
| /** Maximum number of alternate nodes to try during verification */ | ||
| export const MAX_ALTERNATE_NODES = 2; | ||
|
|
||
| /** | ||
| * When the primary node returns null for a get_post call, | ||
| * verify against up to 2 alternate nodes before concluding | ||
| * the post is truly deleted. This guards against sync lag | ||
| * where a single node temporarily returns null for valid content. | ||
| * | ||
| * @param primaryNode - Snapshot of CONFIG.hiveClient.currentAddress captured | ||
| * before the primary request, so failover can't change which node we exclude. | ||
| */ | ||
| export async function verifyPostOnAlternateNode( | ||
| author: string, | ||
| permlink: string, | ||
| observer: string, | ||
| primaryNode?: string | ||
| ): Promise<Entry | null> { | ||
| const allNodes = CONFIG.hiveClient.address; | ||
|
|
||
| // If we can't determine the node list, we can't verify | ||
| if (!Array.isArray(allNodes) || allNodes.length < 2) { | ||
| return null; | ||
| } | ||
|
|
||
| // Filter out the node that just returned null | ||
| const nodeToExclude = primaryNode ?? CONFIG.hiveClient.currentAddress; | ||
| const alternateNodes = nodeToExclude | ||
| ? allNodes.filter((node) => node !== nodeToExclude) | ||
| : [...allNodes]; | ||
|
|
||
| const nodesToTry = alternateNodes.slice(0, MAX_ALTERNATE_NODES); | ||
|
|
||
| for (const node of nodesToTry) { | ||
| try { | ||
| const client = new Client(node, { timeout: 10000 }); | ||
| const response = await client.call("bridge", "get_post", { | ||
| author, | ||
| permlink, | ||
| observer, | ||
| }); | ||
|
|
||
| if ( | ||
| response && | ||
| typeof response === "object" && | ||
| (response as Entry).author === author && | ||
| (response as Entry).permlink === permlink | ||
| ) { | ||
| return response as Entry; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } catch { | ||
| // Node failed — try next one | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.