Skip to content

Commit 513e032

Browse files
Danny-Devsclaude
andcommitted
refactor(client): unify ObjectError across all transports
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR #988 review: 1. Consistent errors: same ObjectError class for JSON-RPC, GraphQL, gRPC 2. No transport knowledge needed: error.code works everywhere 3. instanceof works: single class in client/ layer 4. Not coupled to RPC: no transport imports in client/errors.ts 5. Extra info accessible: raw wire payload exposed via error.transportDetails - ObjectErrorCode is the transport-agnostic union 'notFound' | 'unknown' — the only distinctions every transport can reliably produce. JSON-RPC's five ObjectResponseError wire codes are normalized on the way in: notExists / deleted / dynamicFieldNotFound all surface as 'notFound'; displayError / unknown surface as 'unknown'. The raw wire payload remains available on error.transportDetails for consumers that need it. - Keep objectId non-nullable: when no id is derivable from a JSON-RPC response (e.g. a displayError on listOwnedObjects), escalate to the base SuiClientError instead of fabricating a sentinel - Add TransportDetails, a tagged union ({ \$kind: 'jsonRpc' | 'grpc' | 'graphql' }) lifted to SuiClientError so every subclass inherits the transport escape hatch. ObjectError narrows transportDetails from optional to required via 'declare readonly', since every construction site passes it - Narrow GetObjectsResponse.objects from (Object | Error)[] to (Object | ObjectError)[] and update 'instanceof Error' checks in the composed getObject / getDynamicField / core-resolver paths to the tighter 'instanceof ObjectError'. The gRPC unexpected-oneof case throws SuiClientError (programmer error) instead of surfacing it as data or as a plain Error - Fix gRPC returning plain Error instead of ObjectError; map google.rpc.Code.NOT_FOUND to 'notFound', everything else to 'unknown' (NOT_FOUND is hoisted to a named GRPC_CODE_NOT_FOUND constant) - Replace monolithic mapObjectError with two exhaustive helpers (mapJsonRpcObjectErrorCode + extractObjectIdFromResponseError) that fail to typecheck if a new wire code is introduced upstream - Export TransportDetails from @mysten/sui/client - Unit tests cover: ObjectErrorCode canonical messages, all three transport variants, the SuiClientError escalation path in listOwnedObjects, JSON-RPC getObjects error mapping across all five wire codes (including reference identity of the raw wire payload in transportDetails.response), gRPC status code mapping across notFound and unknown branches, and a universal catch(SuiClientError) contract test that pins compatibility across every wire code - E2E tests use testWithAllClients to enforce cross-transport parity on both getObjects (returns ObjectError) and getObject (throws it) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f89d6a9 commit 513e032

13 files changed

Lines changed: 697 additions & 98 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@mysten/sui': minor
3+
---
4+
5+
Unify `ObjectError` across all transports. `ObjectError` now carries a
6+
transport-agnostic `code: 'notFound' | 'unknown'` and a non-nullable `objectId`,
7+
and works identically regardless of whether the client is backed by JSON-RPC,
8+
gRPC, or GraphQL.
9+
10+
JSON-RPC's five `ObjectResponseError` wire codes (`notExists`, `deleted`,
11+
`dynamicFieldNotFound`, `displayError`, `unknown`) are normalized on the way in:
12+
`notExists`, `deleted`, and `dynamicFieldNotFound` all surface as `'notFound'`;
13+
`displayError` and `unknown` surface as `'unknown'`. The raw wire payload is
14+
still available on `error.transportDetails` for consumers that need the richer
15+
detail.
16+
17+
Adds `TransportDetails`, a tagged union lifted onto the `SuiClientError` base
18+
class that exposes the raw per-transport payload (JSON-RPC response, gRPC
19+
`google.rpc.Status`, or a GraphQL tag) via `error.transportDetails`.
20+
21+
`ObjectError.objectId` is always a real object id. In the rare JSON-RPC case
22+
where the server returns an error that identifies no specific object (e.g. a
23+
`displayError` surfaced during `listOwnedObjects`), a base `SuiClientError`
24+
is thrown instead — consumers who catch `SuiClientError` still catch everything.
25+
26+
Also narrows `GetObjectsResponse.objects` from `(Object | Error)[]` to
27+
`(Object | ObjectError)[]`. Because `ObjectError extends Error`, existing
28+
`instanceof Error` checks continue to work unchanged.
29+
30+
Newly exports `SuiClientError` (base class), `ObjectError`, `ObjectErrorCode`,
31+
and `TransportDetails` from `@mysten/sui/client`. Use `instanceof SuiClientError`
32+
as the universal catch contract for any error originating from the client; use
33+
`instanceof ObjectError` and switch on `error.code` when you need per-object detail.

packages/sui/src/client/core-resolver.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { createCoinReservationRef } from '../utils/coin-reservation.js';
1111
import type { ClientWithCoreApi } from './core.js';
1212
import type { CallArg, Command } from '../transactions/data/internal.js';
1313
import type { SuiClientTypes } from './types.js';
14-
import { SimulationError } from './errors.js';
14+
import { ObjectError, SimulationError } from './errors.js';
1515
import { Inputs } from '../transactions/Inputs.js';
1616
import { getPureBcsSchema, isTxContext } from '../transactions/serializer.js';
1717
import type { TransactionDataBuilder } from '../transactions/TransactionData.js';
@@ -294,17 +294,18 @@ async function resolveObjectReferences(
294294
}),
295295
);
296296

297-
const invalidObjects = Array.from(responsesById)
298-
.filter(([_, obj]) => obj instanceof Error)
299-
.map(([_, obj]) => (obj as Error).message);
300-
301-
if (invalidObjects.length) {
302-
throw new Error(`The following input objects are invalid: ${invalidObjects.join(', ')}`);
297+
// Rethrow the first ObjectError so `instanceof ObjectError` and `error.code` still narrow.
298+
// Multi-invalid aggregation is out of scope; introduce `AggregateObjectError` if needed later.
299+
const firstInvalid = Array.from(responsesById.values()).find(
300+
(obj): obj is ObjectError => obj instanceof ObjectError,
301+
);
302+
if (firstInvalid) {
303+
throw firstInvalid;
303304
}
304305

305306
const objects = resolved.map((object) => {
306-
if (object instanceof Error) {
307-
throw new Error(`Failed to fetch object: ${object.message}`);
307+
if (object instanceof ObjectError) {
308+
throw object;
308309
}
309310
const owner = object.owner;
310311
const initialSharedVersion =

packages/sui/src/client/core.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { TransactionPlugin } from '../transactions/index.js';
66
import { deriveDynamicFieldID } from '../utils/dynamic-fields.js';
77
import { normalizeStructTag, parseStructTag, SUI_ADDRESS_LENGTH } from '../utils/sui-types.js';
88
import { BaseClient } from './client.js';
9+
import { ObjectError } from './errors.js';
910
import type { ClientWithExtensions, SuiClientTypes } from './types.js';
1011
import { MvrClient } from './mvr.js';
1112
import { bcs } from '../bcs/index.js';
@@ -54,7 +55,7 @@ export abstract class CoreClient extends BaseClient implements SuiClientTypes.Tr
5455
signal: options.signal,
5556
include: options.include,
5657
});
57-
if (result instanceof Error) {
58+
if (result instanceof ObjectError) {
5859
throw result;
5960
}
6061
return { object: result };
@@ -148,7 +149,7 @@ export abstract class CoreClient extends BaseClient implements SuiClientTypes.Tr
148149
},
149150
});
150151

151-
if (fieldObject instanceof Error) {
152+
if (fieldObject instanceof ObjectError) {
152153
throw fieldObject;
153154
}
154155

packages/sui/src/client/errors.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,78 @@
11
// Copyright (c) Mysten Labs, Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import type { ObjectResponseError } from '../jsonRpc/index.js';
54
import type { SuiClientTypes } from './types.js';
65

7-
export class SuiClientError extends Error {}
6+
/**
7+
* Structured per-transport escape hatch attached to any `SuiClientError`.
8+
*/
9+
export type TransportDetails =
10+
| {
11+
$kind: 'jsonRpc';
12+
/** The raw `ObjectResponseError` payload from the JSON-RPC response. */
13+
response: unknown;
14+
}
15+
| {
16+
$kind: 'grpc';
17+
/** The `google.rpc.Status` attached to the per-object gRPC result. */
18+
status: { code: number; message: string; details: unknown[] };
19+
}
20+
| {
21+
/** No wire payload — GraphQL omits missing objects rather than emitting structured errors. */
22+
$kind: 'graphql';
23+
};
24+
25+
export class SuiClientError extends Error {
26+
readonly transportDetails?: TransportDetails;
27+
28+
constructor(
29+
message?: string,
30+
options?: { cause?: unknown; transportDetails?: TransportDetails },
31+
) {
32+
super(message, { cause: options?.cause });
33+
this.transportDetails = options?.transportDetails;
34+
}
35+
}
836

937
export class SimulationError extends SuiClientError {
10-
executionError?: SuiClientTypes.ExecutionError;
38+
readonly executionError?: SuiClientTypes.ExecutionError;
1139

1240
constructor(
1341
message: string,
14-
options?: { cause?: unknown; executionError?: SuiClientTypes.ExecutionError },
42+
options?: {
43+
cause?: unknown;
44+
executionError?: SuiClientTypes.ExecutionError;
45+
transportDetails?: TransportDetails;
46+
},
1547
) {
16-
super(message, { cause: options?.cause });
48+
super(message, options);
1749
this.executionError = options?.executionError;
1850
}
1951
}
2052

53+
export type ObjectErrorCode = 'notFound' | 'unknown';
54+
2155
export class ObjectError extends SuiClientError {
22-
code: string;
56+
readonly code: ObjectErrorCode;
57+
readonly objectId: string;
58+
declare readonly transportDetails: TransportDetails;
2359

24-
constructor(code: string, message: string) {
25-
super(message);
60+
constructor(
61+
code: ObjectErrorCode,
62+
objectId: string,
63+
options: { cause?: unknown; transportDetails: TransportDetails },
64+
) {
65+
super(ObjectError.#formatMessage(code, objectId), options);
2666
this.code = code;
67+
this.objectId = objectId;
2768
}
2869

29-
static fromResponse(response: ObjectResponseError, objectId?: string): ObjectError {
30-
switch (response.code) {
31-
case 'notExists':
32-
return new ObjectError(response.code, `Object ${response.object_id} does not exist`);
33-
case 'dynamicFieldNotFound':
34-
return new ObjectError(
35-
response.code,
36-
`Dynamic field not found for object ${response.parent_object_id}`,
37-
);
38-
case 'deleted':
39-
return new ObjectError(response.code, `Object ${response.object_id} has been deleted`);
40-
case 'displayError':
41-
return new ObjectError(response.code, `Display error: ${response.error}`);
70+
static #formatMessage(code: ObjectErrorCode, objectId: string): string {
71+
switch (code) {
72+
case 'notFound':
73+
return `Object not found: ${objectId}`;
4274
case 'unknown':
43-
default:
44-
return new ObjectError(
45-
response.code,
46-
`Unknown error while loading object${objectId ? ` ${objectId}` : ''}`,
47-
);
75+
return `Unknown object error: ${objectId}`;
4876
}
4977
}
5078
}

packages/sui/src/client/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ export {
2222
type ClientWithCoreApi,
2323
};
2424

25-
export { SimulationError } from './errors.js';
25+
export {
26+
SuiClientError,
27+
SimulationError,
28+
ObjectError,
29+
type ObjectErrorCode,
30+
type TransportDetails,
31+
} from './errors.js';
2632

2733
export { ClientCache, type ClientCacheOptions } from './cache.js';
2834
export { type NamedPackagesOverrides } from './mvr.js';

packages/sui/src/client/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import type { Signer } from '../cryptography/keypair.js';
1111
import type { ClientCache } from './cache.js';
1212
import type { BaseClient } from './client.js';
13+
import type { ObjectError } from './errors.js';
1314

1415
export type SuiClientRegistration<
1516
T extends BaseClient = BaseClient,
@@ -141,7 +142,7 @@ export namespace SuiClientTypes {
141142
}
142143

143144
export interface GetObjectsResponse<out Include extends ObjectInclude = {}> {
144-
objects: (Object<Include> | Error)[];
145+
objects: (Object<Include> | ObjectError)[];
145146
}
146147

147148
export interface GetObjectResponse<out Include extends ObjectInclude = {}> {

packages/sui/src/graphql/core.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,17 @@ export class GraphQLCoreClient extends CoreClient {
108108
);
109109
results.push(
110110
...batch
111-
.map((id) => normalizeSuiAddress(id))
112-
.map(
113-
(id) =>
114-
page.find((obj) => obj?.address === id) ??
115-
new ObjectError('notFound', `Object ${id} not found`),
116-
)
111+
.map((rawId) => {
112+
// Normalize for server lookup, but preserve `rawId` on ObjectError so
113+
// `error.objectId` matches what the user passed in.
114+
const normalized = normalizeSuiAddress(rawId);
115+
return (
116+
page.find((obj) => obj?.address === normalized) ??
117+
new ObjectError('notFound', rawId, {
118+
transportDetails: { $kind: 'graphql' },
119+
})
120+
);
121+
})
117122
.map((obj) => {
118123
if (obj instanceof ObjectError) {
119124
return obj;

packages/sui/src/grpc/core.ts

Lines changed: 65 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
// Copyright (c) Mysten Labs, Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import type { CoreClientOptions, SuiClientTypes } from '../client/index.js';
5-
import { CoreClient, formatMoveAbortMessage, SimulationError } from '../client/index.js';
4+
import type { CoreClientOptions, ObjectErrorCode, SuiClientTypes } from '../client/index.js';
5+
import {
6+
CoreClient,
7+
formatMoveAbortMessage,
8+
ObjectError,
9+
SimulationError,
10+
SuiClientError,
11+
} from '../client/index.js';
612
import type { SuiGrpcClient } from './client.js';
713
import type { Owner } from './proto/sui/rpc/v2/owner.js';
814
import { Owner_OwnerKind } from './proto/sui/rpc/v2/owner.js';
@@ -46,6 +52,10 @@ import {
4652
import { Value } from './proto/google/protobuf/struct.js';
4753
import { SimulateTransactionRequest_TransactionChecks } from './proto/sui/rpc/v2/transaction_execution_service.js';
4854

55+
// google.rpc.Code.NOT_FOUND — the only status we map to ObjectError('notFound');
56+
// any other code collapses to 'unknown'.
57+
const GRPC_CODE_NOT_FOUND = 5;
58+
4959
export interface GrpcCoreClientOptions extends CoreClientOptions {
5060
client: SuiGrpcClient;
5161
}
@@ -88,51 +98,59 @@ export class GrpcCoreClient extends CoreClient {
8898
});
8999

90100
results.push(
91-
...response.response.objects.map((object): SuiClientTypes.Object<Include> | Error => {
92-
if (object.result.oneofKind === 'error') {
93-
// TODO: improve error handling
94-
return new Error(object.result.error.message);
95-
}
96-
97-
if (object.result.oneofKind !== 'object') {
98-
return new Error('Unexpected result type');
99-
}
100-
101-
const bcsContent = object.result.object.contents?.value ?? undefined;
102-
const objectBcs = object.result.object.bcs?.value ?? undefined;
103-
104-
// Package objects have type "package" which is not a struct tag, so don't normalize it
105-
const objectType = object.result.object.objectType;
106-
const type =
107-
objectType && objectType.includes('::')
108-
? normalizeStructTag(objectType)
109-
: (objectType ?? '');
110-
111-
const jsonContent = options.include?.json
112-
? object.result.object.json
113-
? (Value.toJson(object.result.object.json) as Record<string, unknown>)
114-
: null
115-
: undefined;
116-
117-
const displayData = mapDisplayProto(
118-
options.include?.display,
119-
object.result.object.display,
120-
);
121-
122-
return {
123-
objectId: object.result.object.objectId!,
124-
version: object.result.object.version?.toString()!,
125-
digest: object.result.object.digest!,
126-
content: bcsContent as SuiClientTypes.Object<Include>['content'],
127-
owner: mapOwner(object.result.object.owner)!,
128-
type,
129-
previousTransaction: (object.result.object.previousTransaction ??
130-
undefined) as SuiClientTypes.Object<Include>['previousTransaction'],
131-
objectBcs: objectBcs as SuiClientTypes.Object<Include>['objectBcs'],
132-
json: jsonContent as SuiClientTypes.Object<Include>['json'],
133-
display: displayData as SuiClientTypes.Object<Include>['display'],
134-
};
135-
}),
101+
...response.response.objects.map(
102+
(object, idx): SuiClientTypes.Object<Include> | ObjectError => {
103+
if (object.result.oneofKind === 'error') {
104+
const status = object.result.error;
105+
const code: ObjectErrorCode =
106+
status.code === GRPC_CODE_NOT_FOUND ? 'notFound' : 'unknown';
107+
return new ObjectError(code, batch[idx], {
108+
transportDetails: { $kind: 'grpc', status },
109+
});
110+
}
111+
112+
if (object.result.oneofKind !== 'object') {
113+
throw new SuiClientError(
114+
`Unexpected gRPC result kind: "${object.result.oneofKind}" — expected "object" or "error"`,
115+
);
116+
}
117+
118+
const bcsContent = object.result.object.contents?.value ?? undefined;
119+
const objectBcs = object.result.object.bcs?.value ?? undefined;
120+
121+
// Package objects have type "package" which is not a struct tag, so don't normalize it
122+
const objectType = object.result.object.objectType;
123+
const type =
124+
objectType && objectType.includes('::')
125+
? normalizeStructTag(objectType)
126+
: (objectType ?? '');
127+
128+
const jsonContent = options.include?.json
129+
? object.result.object.json
130+
? (Value.toJson(object.result.object.json) as Record<string, unknown>)
131+
: null
132+
: undefined;
133+
134+
const displayData = mapDisplayProto(
135+
options.include?.display,
136+
object.result.object.display,
137+
);
138+
139+
return {
140+
objectId: object.result.object.objectId!,
141+
version: object.result.object.version?.toString()!,
142+
digest: object.result.object.digest!,
143+
content: bcsContent as SuiClientTypes.Object<Include>['content'],
144+
owner: mapOwner(object.result.object.owner)!,
145+
type,
146+
previousTransaction: (object.result.object.previousTransaction ??
147+
undefined) as SuiClientTypes.Object<Include>['previousTransaction'],
148+
objectBcs: objectBcs as SuiClientTypes.Object<Include>['objectBcs'],
149+
json: jsonContent as SuiClientTypes.Object<Include>['json'],
150+
display: displayData as SuiClientTypes.Object<Include>['display'],
151+
};
152+
},
153+
),
136154
);
137155
}
138156

0 commit comments

Comments
 (0)