-
Notifications
You must be signed in to change notification settings - Fork 88
refactor(client): unify ObjectError across all transports #988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
513e032
0425ade
81d9c70
75507c0
cd49677
8e294f9
6a142c2
15c2819
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| --- | ||
| '@mysten/sui': minor | ||
| --- | ||
|
|
||
| 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. | ||
| 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}`; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. major: Synthesizing Consumers may make real decisions based on |
||
| transportDetails: { $kind: 'graphql' }, | ||
| }) | ||
| ); | ||
| }) | ||
| .map((obj) => { | ||
| if (obj instanceof ObjectError) { | ||
| return obj; | ||
|
|
@@ -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; | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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, notminor. TheObjectErrorconstructor signature changed from(code: string, message: string)to(code: ObjectErrorCode, objectId: string, options: { transportDetails: TransportDetails })— a source-breaking change for anyone constructingObjectErrordirectly. TheGetObjectsResponse.objectstype narrowing and message format changes are also behavioral breaks.