Skip to content

Commit 26732f3

Browse files
committed
fix: keep proxied RPC requests bounded and surface upstream failures
1 parent 7ba4d76 commit 26732f3

4 files changed

Lines changed: 95 additions & 9 deletions

File tree

packages/api/src/rpc/rpc.controller.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe("RpcController", () => {
3030
it("forwards the request with the session token and returns the response", async () => {
3131
fetchSpy.mockResolvedValueOnce({
3232
status: 200,
33+
ok: true,
3334
json: () => Promise.resolve({ jsonrpc: "2.0", id: 1, result: "0x1" }),
3435
});
3536

@@ -50,8 +51,24 @@ describe("RpcController", () => {
5051
});
5152

5253
it("throws when the permissions API rejects the token", async () => {
53-
fetchSpy.mockResolvedValueOnce({ status: 401, json: () => Promise.resolve({}) });
54+
fetchSpy.mockResolvedValueOnce({ status: 401, ok: false, json: () => Promise.resolve({}) });
5455

5556
await expect(controller.proxy(body, user)).rejects.toThrowError(PrividiumApiError);
5657
});
58+
59+
it("throws a bad gateway when the permissions API fails", async () => {
60+
fetchSpy.mockResolvedValueOnce({ status: 500, ok: false, json: () => Promise.resolve({}) });
61+
62+
await expect(controller.proxy(body, user)).rejects.toThrowError(expect.objectContaining({ status: 502 }) as Error);
63+
});
64+
65+
it("throws a bad gateway when the response is not JSON", async () => {
66+
fetchSpy.mockResolvedValueOnce({
67+
status: 200,
68+
ok: true,
69+
json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")),
70+
});
71+
72+
await expect(controller.proxy(body, user)).rejects.toThrowError(expect.objectContaining({ status: 502 }) as Error);
73+
});
5774
});

packages/api/src/rpc/rpc.controller.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ export class RpcController {
3636
throw new PrividiumApiError("Invalid or expired token", 401);
3737
}
3838

39-
return response.json();
39+
// A JSON-RPC error is carried in a 2xx body, so any other status is a transport failure.
40+
// Without this an upstream 5xx is returned to the app as a 200 with a body ethers cannot parse.
41+
if (!response.ok) {
42+
throw new PrividiumApiError("Invalid response from permissions API", 502);
43+
}
44+
45+
try {
46+
return await response.json();
47+
} catch {
48+
throw new PrividiumApiError("Invalid response from permissions API", 502);
49+
}
4050
}
4151
}

packages/app/src/composables/useContext.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { computed, type ComputedRef, type Ref, ref, watch } from "vue";
22

33
import { useStorage } from "@vueuse/core";
4-
import { FetchRequest, JsonRpcProvider } from "ethers";
4+
import { FetchRequest, JsonRpcProvider, makeError } from "ethers";
55

66
import useEnvironmentConfig from "./useEnvironmentConfig";
77
import { DEFAULT_NETWORK } from "./useRuntimeConfig";
@@ -41,13 +41,35 @@ function getRpcRequest(network: NetworkConfig) {
4141
}
4242

4343
const request = new FetchRequest(`${network.apiUrl}/rpc`);
44-
request.getUrlFunc = async (req) => {
45-
const response = await fetch(req.url, {
46-
method: req.method,
47-
headers: req.headers,
48-
body: req.body,
49-
credentials: "include",
44+
// Overriding getUrlFunc replaces ethers' own fetch, which arms `req.timeout` and forwards
45+
// cancellation, so both are reproduced here to keep requests bounded and abortable.
46+
request.getUrlFunc = async (req, signal) => {
47+
const controller = new AbortController();
48+
let abortError: Error | null = null;
49+
const timer = setTimeout(() => {
50+
abortError = makeError("request timeout", "TIMEOUT");
51+
controller.abort();
52+
}, req.timeout);
53+
signal?.addListener(() => {
54+
abortError = makeError("request cancelled", "CANCELLED");
55+
controller.abort();
5056
});
57+
58+
let response: Response;
59+
try {
60+
response = await fetch(req.url, {
61+
method: req.method,
62+
headers: req.headers,
63+
body: req.body,
64+
credentials: "include",
65+
signal: controller.signal,
66+
});
67+
} catch (error) {
68+
throw abortError ?? error;
69+
} finally {
70+
clearTimeout(timer);
71+
}
72+
5173
const headers: Record<string, string> = {};
5274
response.headers.forEach((value, key) => {
5375
headers[key.toLowerCase()] = value;

packages/app/tests/composables/useContext.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,42 @@ describe("useContext:", () => {
124124
expect(context.getL2Provider()._getConnection().url).toBe("https://api.example.com/rpc");
125125
restore();
126126
});
127+
128+
it("sends session credentials and an abort signal on a Prividium network", async () => {
129+
const { context, restore } = buildContext(PRIVIDIUM_NETWORK);
130+
const fetchMock = vi.spyOn(global, "fetch").mockResolvedValue({
131+
status: 200,
132+
statusText: "OK",
133+
headers: new Headers(),
134+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
135+
} as unknown as Response);
136+
137+
const request = context.getL2Provider()._getConnection();
138+
await request.getUrlFunc(request);
139+
140+
expect(fetchMock).toBeCalledWith(
141+
"https://api.example.com/rpc",
142+
expect.objectContaining({ credentials: "include", signal: expect.any(AbortSignal) })
143+
);
144+
fetchMock.mockRestore();
145+
restore();
146+
});
147+
148+
it("aborts a Prividium request that exceeds the request timeout", async () => {
149+
const { context, restore } = buildContext(PRIVIDIUM_NETWORK);
150+
const fetchMock = vi.spyOn(global, "fetch").mockImplementation(
151+
(_url, init) =>
152+
new Promise((_resolve, reject) => {
153+
(init as RequestInit).signal?.addEventListener("abort", () => reject(new Error("aborted")));
154+
})
155+
);
156+
157+
const request = context.getL2Provider()._getConnection();
158+
request.timeout = 1;
159+
160+
await expect(request.getUrlFunc(request)).rejects.toThrowError(/timeout/);
161+
fetchMock.mockRestore();
162+
restore();
163+
});
127164
});
128165
});

0 commit comments

Comments
 (0)