Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-http-timeout-preauth-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@crossmint/wallets-sdk": patch
"@crossmint/common-sdk-base": patch
---

fix: add default 30s HTTP timeout to ApiClient and gate preAuthIfNeeded on prepareOnly in wallet.send()

- ApiClient.makeRequest() now uses `AbortSignal.timeout(30_000)` by default so requests that never receive a response will reject after 30 seconds instead of hanging indefinitely. Callers can override by passing their own `signal` in `RequestInit`. Timeout rejections are wrapped in a typed `ApiRequestTimeoutError`.
- `wallet.send()` no longer calls `preAuthIfNeeded()` when `prepareOnly` is `true`, preventing unnecessary signer pre-authorization that could stall the call before the HTTP request fires.
107 changes: 107 additions & 0 deletions packages/common/base/src/apiClient/ApiClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiClient, ApiClientError, ApiRequestTimeoutError } from "./ApiClient";

class TestApiClient extends ApiClient {
get commonHeaders(): HeadersInit {
return { "x-test": "true" };
}
get baseUrl(): string {
return "https://api.example.com";
}

// Expose makeRequest indirectly through the public HTTP methods
}

describe("ApiClient - timeout handling", () => {
const originalFetch = globalThis.fetch;

afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});

it("should reject with ApiRequestTimeoutError when fetch times out", async () => {
const timeoutError = new DOMException("The operation was aborted due to timeout", "TimeoutError");
globalThis.fetch = vi.fn().mockRejectedValue(timeoutError);

const client = new TestApiClient();

await expect(client.get("/slow-endpoint", {})).rejects.toThrow(ApiRequestTimeoutError);
await expect(client.get("/slow-endpoint", {})).rejects.toThrow('API request to "/slow-endpoint" timed out');
});

it("should include the path in the ApiRequestTimeoutError", async () => {
const timeoutError = new DOMException("The operation was aborted due to timeout", "TimeoutError");
globalThis.fetch = vi.fn().mockRejectedValue(timeoutError);

const client = new TestApiClient();

try {
await client.get("/my/custom/path", {});
expect.fail("Expected ApiRequestTimeoutError");
} catch (error) {
expect(error).toBeInstanceOf(ApiRequestTimeoutError);
expect((error as ApiRequestTimeoutError).path).toBe("/my/custom/path");
}
});

it("should use caller-supplied signal instead of the default timeout", async () => {
const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 });
globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);

const client = new TestApiClient();
const customController = new AbortController();

await client.get("/endpoint", { signal: customController.signal });

expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
signal: customController.signal,
})
);
});

it("should apply a default AbortSignal.timeout(30_000) when no signal is provided", async () => {
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 });
globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);

const client = new TestApiClient();
await client.get("/endpoint", {});

expect(timeoutSpy).toHaveBeenCalledWith(30_000);
});

it("should re-throw non-timeout errors as-is", async () => {
const networkError = new Error("Network failure");
globalThis.fetch = vi.fn().mockRejectedValue(networkError);

const client = new TestApiClient();

await expect(client.get("/endpoint", {})).rejects.toThrow("Network failure");
await expect(client.get("/endpoint", {})).rejects.not.toBeInstanceOf(ApiRequestTimeoutError);
});

it("should re-throw non-timeout DOMExceptions as-is", async () => {
const abortError = new DOMException("The operation was aborted", "AbortError");
globalThis.fetch = vi.fn().mockRejectedValue(abortError);

const client = new TestApiClient();

await expect(client.get("/endpoint", {})).rejects.toThrow(DOMException);
await expect(client.get("/endpoint", {})).rejects.not.toBeInstanceOf(ApiRequestTimeoutError);
});

it("should still throw ApiClientError on 5xx responses", async () => {
const mockResponse = new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
});
globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);

const client = new TestApiClient();

await expect(client.get("/endpoint", {})).rejects.toThrow(ApiClientError);
});
});
24 changes: 20 additions & 4 deletions packages/common/base/src/apiClient/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,31 @@ export class ApiClientError extends Error {
}
}

export class ApiRequestTimeoutError extends Error {
constructor(public readonly path: string) {
super(`API request to "${path}" timed out`);
this.name = "ApiRequestTimeoutError";
}
}

export abstract class ApiClient {
abstract get commonHeaders(): HeadersInit;
abstract get baseUrl(): string;

private async makeRequest(path: string, init: RequestInit) {
const response = await fetch(this.buildUrl(path), {
...init,
headers: { ...this.commonHeaders, ...init.headers }, // commonHeaders intentionally first, in case sub class wants to override
});
let response: Response;
try {
response = await fetch(this.buildUrl(path), {
...init,
headers: { ...this.commonHeaders, ...init.headers }, // commonHeaders intentionally first, in case sub class wants to override
signal: init.signal ?? AbortSignal.timeout(30_000),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded timeout with no override at the client level

The 30-second timeout is hardcoded with no way to adjust it per ApiClient instance. Callers can override it per-request by passing their own signal, but there is no way to say "this client should always time out in 10 s" without wrapping every call. Consider accepting an optional defaultTimeoutMs in the ApiClient constructor so subclasses can configure it without touching call sites.

Suggested change
signal: init.signal ?? AbortSignal.timeout(30_000),
signal: init.signal ?? AbortSignal.timeout(this.defaultTimeoutMs ?? 30_000),
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/common/base/src/apiClient/ApiClient.ts
Line: 30

Comment:
**Hardcoded timeout with no override at the client level**

The 30-second timeout is hardcoded with no way to adjust it per `ApiClient` instance. Callers can override it per-request by passing their own `signal`, but there is no way to say "this client should always time out in 10 s" without wrapping every call. Consider accepting an optional `defaultTimeoutMs` in the `ApiClient` constructor so subclasses can configure it without touching call sites.

```suggestion
                signal: init.signal ?? AbortSignal.timeout(this.defaultTimeoutMs ?? 30_000),
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree this would be a nice enhancement, but treating it as out of scope for this bug-fix PR to keep the change minimal. The per-request signal override is sufficient for callers who need a different timeout today. A defaultTimeoutMs property on ApiClient could be added in a follow-up if subclasses need instance-level configuration.

});
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "TimeoutError") {
throw new ApiRequestTimeoutError(path);
}
throw error;
}

// Only throw on server errors (5xx) where the response body is likely
// non-JSON (e.g. HTML 502 from load balancer). 4xx responses are passed
Expand Down
45 changes: 45 additions & 0 deletions packages/wallets/src/wallets/wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,51 @@ describe("Wallet - send()", () => {
expect(mockApiClient.send).not.toHaveBeenCalled();
});
});

describe("preAuthIfNeeded gating", () => {
it("should NOT call preAuthIfNeeded when prepareOnly is true", async () => {
const mockSendResponse = {
id: "txn-prep-1",
} as unknown as SendResponse;

mockApiClient.send.mockResolvedValue(mockSendResponse);

const preAuthSpy = vi.spyOn(wallet as any, "preAuthIfNeeded");

await wallet.send("0x1111111111111111111111111111111111111111", "usdc", "10.0", {
prepareOnly: true,
});

expect(preAuthSpy).not.toHaveBeenCalled();
expect(mockApiClient.send).toHaveBeenCalled();
});

it("should call preAuthIfNeeded when prepareOnly is not set", async () => {
const mockSendResponse = {
id: "txn-normal-1",
} as unknown as SendResponse;

const mockTransactionResponse = {
id: "txn-normal-1",
status: "success",
onChain: {
txId: "0xabc",
explorerLink: "https://explorer.example.com/tx/0xabc",
},
};

mockApiClient.send.mockResolvedValue(mockSendResponse);
mockApiClient.getTransaction.mockResolvedValue(mockTransactionResponse as any);

const preAuthSpy = vi.spyOn(wallet as any, "preAuthIfNeeded").mockResolvedValue(undefined);

const sendPromise = wallet.send("0x1111111111111111111111111111111111111111", "usdc", "10.0");
await vi.runAllTimersAsync();
await sendPromise;

expect(preAuthSpy).toHaveBeenCalled();
});
});
});

describe("Wallet - approve()", () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/wallets/src/wallets/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,11 @@ export class Wallet<C extends Chain> {
...(options?.transactionType != null ? { transactionType: options.transactionType } : {}),
});

await this.preAuthIfNeeded();
if (!options?.prepareOnly) {
await this.preAuthIfNeeded();
} else {
await this.#signerInitialization;
}
const walletSigner = this.requireSigner();
Comment on lines +511 to 516
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 #signerInitialization race skipped for prepareOnly

preAuthIfNeeded() starts with await this.#signerInitialization, which is the only place that waits for initDefaultSigner() to finish auto-assembling this.#signer. By skipping preAuthIfNeeded() entirely for prepareOnly, requireSigner() on line 514 can be reached while initDefaultSigner() is still running — leaving this.#signer === undefined and causing requireSigner() to throw "This wallet is read-only" even though the signer would be available moments later. The tests don't expose this because createMockWallet calls await wallet.useSigner(signer) before any test runs, bypassing initDefaultSigner() entirely.

A minimal fix is to await initialization in the prepareOnly branch before calling requireSigner():

if (!options?.prepareOnly) {
    await this.preAuthIfNeeded();
} else {
    await this.#signerInitialization; // ensure auto-assembly completes
}
const walletSigner = this.requireSigner();
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/wallets/wallet.ts
Line: 511-514

Comment:
**`#signerInitialization` race skipped for `prepareOnly`**

`preAuthIfNeeded()` starts with `await this.#signerInitialization`, which is the only place that waits for `initDefaultSigner()` to finish auto-assembling `this.#signer`. By skipping `preAuthIfNeeded()` entirely for `prepareOnly`, `requireSigner()` on line 514 can be reached while `initDefaultSigner()` is still running — leaving `this.#signer === undefined` and causing `requireSigner()` to throw "This wallet is read-only" even though the signer would be available moments later. The tests don't expose this because `createMockWallet` calls `await wallet.useSigner(signer)` before any test runs, bypassing `initDefaultSigner()` entirely.

A minimal fix is to await initialization in the `prepareOnly` branch before calling `requireSigner()`:

```
if (!options?.prepareOnly) {
    await this.preAuthIfNeeded();
} else {
    await this.#signerInitialization; // ensure auto-assembly completes
}
const walletSigner = this.requireSigner();
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed this is a real race. preAuthIfNeeded() awaits this.#signerInitialization (line 1404), and by skipping the entire call for prepareOnly we'd let requireSigner() run before initDefaultSigner() completes.

Fixed in 9726fd4 — added await this.#signerInitialization in the else branch:

if (!options?.prepareOnly) {
    await this.preAuthIfNeeded();
} else {
    await this.#signerInitialization;
}
const walletSigner = this.requireSigner();


let signer: string;
Expand Down
Loading