What happens
CandidAdapter.fetchFromTmpHack and CandidAdapter.compileRemote report a query that got no reply with
throw new Error(`Query failed: ${JSON.stringify(response)}`)
(packages/candid/src/adapter.ts:381 and :429 on origin/main c39bf67).
For a rejected query, HttpAgent.query returns the replica's node signatures along with the reject: signatures: [{ timestamp, signature, identity }]. The agent decodes each timestamp (a CBOR u64, in nanoseconds) as a bigint. JSON.stringify throws TypeError: Do not know how to serialize a BigInt on it, so every rejected query ends in that TypeError and the reject code and message are lost.
This affects every rejection, not an edge case. The interface spec gives every query response at least one node signature (signatures ([+ node-signature])), and the agent refuses a response without one when it verifies query signatures, which is its default.
The most visible case is a canister with no Candid: no candid:service metadata and no __get_candid_interface_tmp_hack method. The replica rejects the fallback query with Error from Canister <id>: Canister has no query method '__get_candid_interface_tmp_hack'. (reject code 5, IC0536). getCandidDefinition() and CandidReactor.initialize() then say:
Failed to retrieve Candid source by any method: the candid:service metadata was not available; calling __get_candid_interface_tmp_hack failed: Do not know how to serialize a BigInt
#541 added each attempt's cause to this message so that "no Candid" could be told apart from a network or root-key failure. On this path, the cause it shows is the TypeError.
compileRemote loses the didjs canister's reject the same way. Take a local replica with no didjs canister: the reject is Canister bd3sg-teaaa-aaaaa-qaaba-cai not found (reject code 3, IC0301). parseCandidSource without the local parser then reports Do not know how to serialize a BigInt.
Repro (measured on origin/main c39bf67)
Agent level, plain Node with @icp-sdk/core 6.1.0. A fetch answers /api/v3/canister/<id>/query with the CBOR body a replica sends for a reject. agent.query(...) resolves to an object with the keys requestDetails, status, reject_code, reject_message, error_code, signatures, httpDetails, requestId. typeof response.signatures[0].timestamp is "bigint", and JSON.stringify(response) throws TypeError: Do not know how to serialize a BigInt.
Adapter level: the real HttpAgent, with query signature verification on, against a fetch that answers like a replica. The body is { status: "rejected", reject_code, reject_message, error_code, signatures: [{ timestamp: <u64 ns>, signature, identity }] }, carrying an Ed25519 node signature over the reject. The node's key is put in the agent's subnetNodeKeyExpirableStore.
const adapter = new CandidAdapter({
clientManager: { agent, isLocal: false, subscribe: () => () => {} },
})
await adapter.fetchFromTmpHack("ryjl3-tyaaa-aaaaa-aaaba-cai")
// origin/main: Error: Do not know how to serialize a BigInt
The existing unit tests stub agent.query with { reject_code, reject_message }, which has no signatures. The stubbed object serializes without error, so those tests never saw the throw.
Why it matters
The reject message is the only thing that says why the Candid couldn't be fetched or compiled: a missing method, a missing canister, a stopped canister, a canister out of cycles. The candid adapter docs ("Error Handling") tell users to pass the "Failed to retrieve Candid source" message on, because "the rest of the message says what stopped each attempt". On this path it doesn't.
Fix plan
Build the error from the reject fields instead of serializing the response:
Query failed (reject code 5, error code IC0536): Error from Canister ryjl3-tyaaa-aaaaa-aaaba-cai: Canister has no query method '__get_candid_interface_tmp_hack'.
- Keep the
Query failed prefix, since existing tests and user code may match on it.
- Apply the change to both
fetchFromTmpHack and compileRemote.
- Put the reject message last, because a replica message can end with a line of help and a link.
- Leave out the error code when the replica sends none; the spec calls it optional.
Alternative considered: reuse core's processQueryCallResponse and throw the SDK's RejectError. Direct callers of these public methods would get a different error class, and the SDK's multi-line message with the request ID. The mocked responses in adapter.test.ts would also need a status. Not chosen.
Acceptance
What happens
CandidAdapter.fetchFromTmpHackandCandidAdapter.compileRemotereport a query that got no reply with(
packages/candid/src/adapter.ts:381and:429on origin/main c39bf67).For a rejected query,
HttpAgent.queryreturns the replica's node signatures along with the reject:signatures: [{ timestamp, signature, identity }]. The agent decodes eachtimestamp(a CBOR u64, in nanoseconds) as abigint.JSON.stringifythrowsTypeError: Do not know how to serialize a BigInton it, so every rejected query ends in that TypeError and the reject code and message are lost.This affects every rejection, not an edge case. The interface spec gives every query response at least one node signature (
signatures ([+ node-signature])), and the agent refuses a response without one when it verifies query signatures, which is its default.The most visible case is a canister with no Candid: no
candid:servicemetadata and no__get_candid_interface_tmp_hackmethod. The replica rejects the fallback query withError from Canister <id>: Canister has no query method '__get_candid_interface_tmp_hack'.(reject code 5, IC0536).getCandidDefinition()andCandidReactor.initialize()then say:#541 added each attempt's cause to this message so that "no Candid" could be told apart from a network or root-key failure. On this path, the cause it shows is the TypeError.
compileRemoteloses the didjs canister's reject the same way. Take a local replica with no didjs canister: the reject isCanister bd3sg-teaaa-aaaaa-qaaba-cai not found(reject code 3, IC0301).parseCandidSourcewithout the local parser then reportsDo not know how to serialize a BigInt.Repro (measured on origin/main c39bf67)
Agent level, plain Node with
@icp-sdk/core6.1.0. Afetchanswers/api/v3/canister/<id>/querywith the CBOR body a replica sends for a reject.agent.query(...)resolves to an object with the keysrequestDetails, status, reject_code, reject_message, error_code, signatures, httpDetails, requestId.typeof response.signatures[0].timestampis"bigint", andJSON.stringify(response)throwsTypeError: Do not know how to serialize a BigInt.Adapter level: the real
HttpAgent, with query signature verification on, against afetchthat answers like a replica. The body is{ status: "rejected", reject_code, reject_message, error_code, signatures: [{ timestamp: <u64 ns>, signature, identity }] }, carrying an Ed25519 node signature over the reject. The node's key is put in the agent'ssubnetNodeKeyExpirableStore.The existing unit tests stub
agent.querywith{ reject_code, reject_message }, which has no signatures. The stubbed object serializes without error, so those tests never saw the throw.Why it matters
The reject message is the only thing that says why the Candid couldn't be fetched or compiled: a missing method, a missing canister, a stopped canister, a canister out of cycles. The candid adapter docs ("Error Handling") tell users to pass the "Failed to retrieve Candid source" message on, because "the rest of the message says what stopped each attempt". On this path it doesn't.
Fix plan
Build the error from the reject fields instead of serializing the response:
Query failedprefix, since existing tests and user code may match on it.fetchFromTmpHackandcompileRemote.Alternative considered: reuse core's
processQueryCallResponseand throw the SDK'sRejectError. Direct callers of these public methods would get a different error class, and the SDK's multi-line message with the request ID. The mocked responses inadapter.test.tswould also need astatus. Not chosen.Acceptance
fetchFromTmpHackandcompileRemotecarry the reject code, error code and reject message of a real, signed rejected query.fetchCandidSourcefor a canister with no Candid ends in the replica's reject, in candid: 'Failed to retrieve Candid source by any method' hides why both attempts failed #541's format.Query failedprefix is kept.