Skip to content
Open
47 changes: 47 additions & 0 deletions .changeset/unified-object-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@mysten/sui': minor
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.

major: This should be major, not minor. The ObjectError constructor signature changed from (code: string, message: string) to (code: ObjectErrorCode, objectId: string, options: { transportDetails: TransportDetails }) — a source-breaking change for anyone constructing ObjectError directly. The GetObjectsResponse.objects type narrowing and message format changes are also behavioral breaks.

---

Unify `ObjectError` across all transports. `ObjectError` now carries a
transport-agnostic `code: 'notFound' | 'deleted' | 'unknown'` and a non-nullable
`objectId`, and works identically regardless of whether the client is backed by
JSON-RPC, gRPC, or GraphQL.

`ObjectError`'s constructor `options` arg is now optional — consumers can
construct `ObjectError` directly without ceremony, and the base-class
`transportDetails` field is honestly optional.

JSON-RPC distinguishes `'deleted'` from never-existed at the wire level, so it
maps `deleted` → `'deleted'` and `notExists`/`dynamicFieldNotFound` → `'notFound'`.
gRPC and GraphQL cannot distinguish (gRPC's `NOT_FOUND` is not specific; GraphQL
omits absent objects without saying why), so both collapse to `'notFound'`. The
raw wire payload is preserved on `error.transportDetails` for consumers who need
to discriminate further.

When `client.core.getObjects` resolves multiple invalid ids in a transaction,
the new `AggregateObjectError extends SuiClientError` is thrown with all
errors on `.errors`. A single invalid id still throws the bare `ObjectError`.

Adds `TransportDetails`, a tagged union lifted onto the `SuiClientError` base
class that exposes the raw per-transport payload (JSON-RPC response, gRPC
`google.rpc.Status`, or a GraphQL tag) via `error.transportDetails`.

`ObjectError.objectId` is always a real object id. In the rare JSON-RPC case
where the server returns an error that identifies no specific object (e.g. a
`displayError` surfaced during `listOwnedObjects`), a base `SuiClientError`
is thrown instead — consumers who catch `SuiClientError` still catch everything.

`GraphQLResponseError` now extends `SuiClientError`, and the multi-error path
wraps the aggregate in `SuiClientError` with `transportDetails: { $kind: 'graphql' }`
on the cause. `instanceof SuiClientError` is now genuinely universal across
all three transports.

Also narrows `GetObjectsResponse.objects` from `(Object | Error)[]` to
`(Object | ObjectError)[]`. Because `ObjectError extends Error`, existing
`instanceof Error` checks continue to work unchanged.

Newly exports `SuiClientError` (base class), `ObjectError`,
`AggregateObjectError`, `ObjectErrorCode`, and `TransportDetails` from
`@mysten/sui/client`. Use `instanceof SuiClientError` as the universal catch
contract for any error originating from the client; use `instanceof ObjectError`
and switch on `error.code` when you need per-object detail.
21 changes: 10 additions & 11 deletions packages/sui/src/client/core-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createCoinReservationRef } from '../utils/coin-reservation.js';
import type { ClientWithCoreApi } from './core.js';
import type { CallArg, Command } from '../transactions/data/internal.js';
import type { SuiClientTypes } from './types.js';
import { SimulationError } from './errors.js';
import { AggregateObjectError, ObjectError, SimulationError } from './errors.js';
import { Inputs } from '../transactions/Inputs.js';
import { getPureBcsSchema, isTxContext } from '../transactions/serializer.js';
import type { TransactionDataBuilder } from '../transactions/TransactionData.js';
Expand Down Expand Up @@ -329,18 +329,17 @@ async function resolveObjectReferences(
}),
);

const invalidObjects = Array.from(responsesById)
.filter(([_, obj]) => obj instanceof Error)
.map(([_, obj]) => (obj as Error).message);

if (invalidObjects.length) {
throw new Error(`The following input objects are invalid: ${invalidObjects.join(', ')}`);
const objectErrors = Array.from(responsesById.values()).filter(
(obj): obj is ObjectError => obj instanceof ObjectError,
);
if (objectErrors.length === 1) {
throw objectErrors[0];
}
if (objectErrors.length > 1) {
throw new AggregateObjectError(objectErrors);
}

const objects = resolved.map((object) => {
if (object instanceof Error) {
throw new Error(`Failed to fetch object: ${object.message}`);
}
const objects = (resolved as Exclude<(typeof resolved)[number], ObjectError>[]).map((object) => {
const owner = object.owner;
const initialSharedVersion =
owner && typeof owner === 'object'
Expand Down
5 changes: 3 additions & 2 deletions packages/sui/src/client/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { TransactionPlugin } from '../transactions/index.js';
import { deriveDynamicFieldID } from '../utils/dynamic-fields.js';
import { normalizeStructTag, parseStructTag, SUI_ADDRESS_LENGTH } from '../utils/sui-types.js';
import { BaseClient } from './client.js';
import { ObjectError } from './errors.js';
import type { ClientWithExtensions, SuiClientTypes } from './types.js';
import { MvrClient } from './mvr.js';
import { bcs } from '../bcs/index.js';
Expand Down Expand Up @@ -54,7 +55,7 @@ export abstract class CoreClient extends BaseClient implements SuiClientTypes.Tr
signal: options.signal,
include: options.include,
});
if (result instanceof Error) {
if (result instanceof ObjectError) {
throw result;
}
return { object: result };
Expand Down Expand Up @@ -148,7 +149,7 @@ export abstract class CoreClient extends BaseClient implements SuiClientTypes.Tr
},
});

if (fieldObject instanceof Error) {
if (fieldObject instanceof ObjectError) {
throw fieldObject;
}

Expand Down
93 changes: 68 additions & 25 deletions packages/sui/src/client/errors.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,93 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import type { ObjectResponseError } from '../jsonRpc/index.js';
import type { SuiClientTypes } from './types.js';

export class SuiClientError extends Error {}
/**
* Structured per-transport escape hatch attached to any `SuiClientError`.
*/
export type TransportDetails =
| {
$kind: 'jsonRpc';
/** The raw `ObjectResponseError` payload from the JSON-RPC response. */
response: unknown;
}
| {
$kind: 'grpc';
/** The `google.rpc.Status` attached to the per-object gRPC result. */
status: { code: number; message: string; details: unknown[] };
}
| {
/** No wire payload — GraphQL omits missing objects rather than emitting structured errors. */
$kind: 'graphql';
};

export class SuiClientError extends Error {
readonly transportDetails?: TransportDetails;

constructor(
message?: string,
options?: { cause?: unknown; transportDetails?: TransportDetails },
) {
super(message, { cause: options?.cause });
this.transportDetails = options?.transportDetails;
}
}

export class SimulationError extends SuiClientError {
executionError?: SuiClientTypes.ExecutionError;
readonly executionError?: SuiClientTypes.ExecutionError;

constructor(
message: string,
options?: { cause?: unknown; executionError?: SuiClientTypes.ExecutionError },
options?: {
cause?: unknown;
executionError?: SuiClientTypes.ExecutionError;
transportDetails?: TransportDetails;
},
) {
super(message, { cause: options?.cause });
super(message, options);
this.executionError = options?.executionError;
}
}

export type ObjectErrorCode = 'notFound' | 'deleted' | 'unknown';

export class ObjectError extends SuiClientError {
code: string;
readonly code: ObjectErrorCode;
readonly objectId: string;

constructor(code: string, message: string) {
super(message);
constructor(
code: ObjectErrorCode,
objectId: string,
options?: { cause?: unknown; transportDetails?: TransportDetails },
) {
super(ObjectError.#formatMessage(code, objectId), options);
this.code = code;
this.objectId = objectId;
}

static fromResponse(response: ObjectResponseError, objectId?: string): ObjectError {
switch (response.code) {
case 'notExists':
return new ObjectError(response.code, `Object ${response.object_id} does not exist`);
case 'dynamicFieldNotFound':
return new ObjectError(
response.code,
`Dynamic field not found for object ${response.parent_object_id}`,
);
static #formatMessage(code: ObjectErrorCode, objectId: string): string {
switch (code) {
case 'notFound':
return `Object not found: ${objectId}`;
case 'deleted':
return new ObjectError(response.code, `Object ${response.object_id} has been deleted`);
case 'displayError':
return new ObjectError(response.code, `Display error: ${response.error}`);
return `Object deleted: ${objectId}`;
case 'unknown':
default:
return new ObjectError(
response.code,
`Unknown error while loading object${objectId ? ` ${objectId}` : ''}`,
);
return `Unknown object error: ${objectId}`;
}
}
}

export class AggregateObjectError extends SuiClientError {
readonly errors: ObjectError[];

constructor(errors: ObjectError[], options?: { cause?: unknown }) {
super(AggregateObjectError.#formatMessage(errors), options);
this.errors = errors;
}

static #formatMessage(errors: ObjectError[]): string {
const ids = errors.map((e) => e.objectId).join(', ');
return `${errors.length} object errors: ${ids}`;
}
}
9 changes: 8 additions & 1 deletion packages/sui/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export {
type ClientWithCoreApi,
};

export { SimulationError } from './errors.js';
export {
SuiClientError,
SimulationError,
ObjectError,
AggregateObjectError,
type ObjectErrorCode,
type TransportDetails,
} from './errors.js';

export { ClientCache, type ClientCacheOptions } from './cache.js';
export { type NamedPackagesOverrides } from './mvr.js';
3 changes: 2 additions & 1 deletion packages/sui/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import type { Signer } from '../cryptography/keypair.js';
import type { ClientCache } from './cache.js';
import type { BaseClient } from './client.js';
import type { ObjectError } from './errors.js';

export type SuiClientRegistration<
T extends BaseClient = BaseClient,
Expand Down Expand Up @@ -141,7 +142,7 @@ export namespace SuiClientTypes {
}

export interface GetObjectsResponse<out Include extends ObjectInclude = {}> {
objects: (Object<Include> | Error)[];
objects: (Object<Include> | ObjectError)[];
}

export interface GetObjectResponse<out Include extends ObjectInclude = {}> {
Expand Down
28 changes: 18 additions & 10 deletions packages/sui/src/graphql/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
VerifyZkLoginSignatureDocument,
ZkLoginIntentScope,
} from './generated/queries.js';
import { ObjectError, SimulationError } from '../client/errors.js';
import { ObjectError, SimulationError, SuiClientError } from '../client/errors.js';
import { chunk, fromBase64, toBase64 } from '@mysten/utils';
import { normalizeSuiAddress } from '../utils/sui-types.js';
import { formatMoveAbortMessage, parseTransactionEffectsBcs } from '../client/utils.js';
Expand Down Expand Up @@ -108,12 +108,17 @@ export class GraphQLCoreClient extends CoreClient {
);
results.push(
...batch
.map((id) => normalizeSuiAddress(id))
.map(
(id) =>
page.find((obj) => obj?.address === id) ??
new ObjectError('notFound', `Object ${id} not found`),
)
.map((rawId) => {
// Normalize for lookup, but echo rawId back on ObjectError.
// GraphQL omits absent objects without saying why — cannot distinguish 'deleted'.
const normalized = normalizeSuiAddress(rawId);
return (
page.find((obj) => obj?.address === normalized) ??
new ObjectError('notFound', rawId, {
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.

major: Synthesizing ObjectError('notFound', ...) when an object is absent from the GraphQL result page overclaims certainty. Absence could mean partial indexing, eventual consistency, authorization filtering, resolver bugs, or rate limiting — not just "not found." The GraphQL transport details are empty ({ $kind: 'graphql' }) so there's no recovery path.

Consumers may make real decisions based on code === 'notFound' (create-if-absent, purge cache, etc.) that would be wrong here. Consider 'unknown' unless the GraphQL schema contract explicitly guarantees absence semantics.

transportDetails: { $kind: 'graphql' },
})
);
})
.map((obj) => {
if (obj instanceof ObjectError) {
return obj;
Expand Down Expand Up @@ -806,14 +811,17 @@ function handleGraphQLErrors(errors: GraphQLResponseErrors | undefined): void {
throw errorInstances[0];
}

throw new AggregateError(errorInstances);
throw new SuiClientError(`GraphQL response returned ${errorInstances.length} errors`, {
cause: new AggregateError(errorInstances),
transportDetails: { $kind: 'graphql' },
});
}

class GraphQLResponseError extends Error {
class GraphQLResponseError extends SuiClientError {
locations?: Array<{ line: number; column: number }>;

constructor(error: GraphQLResponseErrors[0]) {
super(error.message);
super(error.message, { transportDetails: { $kind: 'graphql' } });
this.locations = error.locations;
}
}
Expand Down
Loading