refactor(client): unify ObjectError across all transports#988
refactor(client): unify ObjectError across all transports#988Danny-Devs wants to merge 7 commits intoMystenLabs:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
b63983f to
c39b666
Compare
hayes-mysten
left a comment
There was a problem hiding this comment.
This looks pretty good in theory.
The plan here was for all 3 core client to emit ObjectError instances, but that somehow got missed as the other core clients got built out.
Generally speaking all of the core clients should have identical behavior. I think the correct thing to do here would be to have the grpc and graphql clients use (or extend) the existing ObjectError.
It unfortunate that this currently has a different concept of code that does not match up with what we do in grpc.
I am a little hesitant to merge this as is, because it only complicates the existing inconsistency. I think this is better than what we have on the json rpc side, but I want to try to move towards something consistent rather than fragmenting the clients more
|
Searching the repo, Proposed shape — an abstract // client/errors.ts — transport-agnostic abstract base
export abstract class ObjectError extends SuiClientError {
abstract readonly $kind: 'JsonRpc' | 'Grpc' | 'GraphQL';
}
// client/errors.ts — JSON-RPC variant
export class JsonRpcObjectError extends ObjectError {
readonly $kind = 'JsonRpc' as const;
readonly code: string;
readonly data?: unknown;
constructor(code: string, message: string, data?: unknown) {
super(message);
this.code = code;
this.data = data;
}
}
// grpc/errors.ts — gRPC variant (imports Status from proto)
export class GrpcObjectError extends ObjectError {
readonly $kind = 'Grpc' as const;
readonly code: number;
readonly details: readonly { typeUrl: string; value: Uint8Array }[];
constructor(status: Status) {
super(status.message);
this.code = status.code;
this.details = status.details;
}
}
// graphql/errors.ts — GraphQL variant
export class GraphQLObjectError extends ObjectError {
readonly $kind = 'GraphQL' as const;
readonly code: string;
readonly extensions?: Record<string, unknown>;
constructor(code: string, message: string, extensions?: Record<string, unknown>) {
super(message);
this.code = code;
this.extensions = extensions;
}
}Consumer narrowing via catch (e) {
if (e instanceof GrpcObjectError && e.code === GrpcStatusCode.UNAVAILABLE) {
retryWithBackoff(); // e.details is in scope
} else if (e instanceof JsonRpcObjectError && e.code === 'notExists') {
surfaceToUser(e.message);
} else if (e instanceof ObjectError) {
surfaceToUser(e.message); // any other variant
}
}Why this shape:
One design question before I draft the PR: should Happy to split this into two PRs if it helps review: PR1 = the |
|
I don't think this solution solves the original problem well. If I understand the problem correctly, when using the core API you can't tell why an object errored. This solution means you need to know the which RPC implementation is being used, and the specific error code format and meanings for that rpc. The proposed hierarchy is pretty confusing and its not clear and doubles down on the bad error patterns we already have. I think we probably should take a step back and think through how errors should be surfaced in general.
I think given this, what we probably want is:
I don't have an exact implementation in mind, but I think something that doesn't specialize the error class per implementation would be better here |
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#988 review: 1. Consistent errors: same ObjectError class for JSON-RPC, GraphQL, gRPC 2. No transport knowledge needed: error.code === 'notFound' works everywhere 3. instanceof works: single class in client/ layer 4. Not coupled to RPC: no jsonRpc imports in client/errors.ts 5. Extra info accessible: raw transport error preserved via Error.cause - Add ObjectErrorCode type ('notFound' | 'unknown') with canonical codes - Add objectId as a top-level readonly property - Generate canonical messages from code + objectId - Fix gRPC returning plain Error instead of ObjectError - Move JSON-RPC response mapping out of ObjectError into transport layer - Export ObjectError and ObjectErrorCode from @mysten/sui/client Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
c39b666 to
fd9d86e
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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 - Widen ObjectErrorCode to the semantic union ('notFound' | 'deleted' | 'dynamicFieldNotFound' | 'displayError' | 'unknown') - 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 - Fix gRPC returning plain Error instead of ObjectError; map google.rpc.Code.NOT_FOUND (5) to 'notFound', everything else to 'unknown' - 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 all 5 codes, all 3 transport variants, and the SuiClientError escalation path in listOwnedObjects - E2E tests use testWithAllClients to enforce cross-transport parity on both getObjects (returns ObjectError) and getObject (throws it) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fd9d86e to
ff0b0bb
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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 - Narrow GetObjectsResponse.objects from (Object | Error)[] to (Object | ObjectError)[]; existing 'instanceof Error' checks continue to work unchanged because ObjectError extends Error. The gRPC unexpected-oneof case becomes a throw (programmer error) instead of being surfaced as data - Fix gRPC returning plain Error instead of ObjectError; map google.rpc.Code.NOT_FOUND (5) to 'notFound', everything else to 'unknown' - 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 both ObjectErrorCode values, all three transport variants, and the SuiClientError escalation path in listOwnedObjects - E2E tests use testWithAllClients to enforce cross-transport parity on both getObjects (returns ObjectError) and getObject (throws it) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ff0b0bb to
041c669
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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.6 <noreply@anthropic.com>
041c669 to
17d6367
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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.6 <noreply@anthropic.com>
17d6367 to
641cdfe
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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.6 <noreply@anthropic.com>
641cdfe to
7b136e0
Compare
There was a problem hiding this comment.
Deep review — this PR does not satisfy the stated requirements
I did a thorough multi-pass review measuring this against Hayes's five requirements from the prior comment. The core goal — instanceof ObjectError parity across transports — is partially achieved, but there are significant design issues, behavioral regressions, and a semver story that isn't honest.
Critical issues
1. TransportDetails is required on ObjectError, leaking transport into the public API (violates req #2)
ObjectError constructor requires transportDetails: TransportDetails — a tagged union with $kind: 'jsonRpc' | 'grpc' | 'graphql'. This means any code constructing, mocking, or testing an ObjectError must manufacture a transport tag. The tests themselves prove the smell: they had to invent const ANY_TRANSPORT: TransportDetails = { $kind: 'graphql' } just to create test errors. If transportDetails is an escape hatch for power users, it should be optional on ObjectError, not mandatory.
2. ObjectErrorCode = 'notFound' | 'unknown' is too lossy (violates req #4)
JSON-RPC's deleted was a distinct, useful code — consumers may need to distinguish "never existed" from "existed but deleted." This PR flattens deleted into 'notFound'. The only way to recover that distinction is now error.transportDetails.$kind === 'jsonRpc' && error.transportDetails.response.code === 'deleted' — which directly violates requirement #4 ("decisions based on error details should not be tied to an RPC implementation"). At minimum, ObjectErrorCode should include 'deleted' as a normalized code. If gRPC/GraphQL can't distinguish it, they emit a subset — that's fine.
3. listOwnedObjects inconsistency — same wire codes get different error types depending on method
For displayError/unknown wire codes:
getObjects()→ returnsObjectError('unknown', batch[idx], ...)listOwnedObjects()→ throws plainSuiClientError(notObjectError)
This is because the new ObjectError requires non-null objectId, and displayError/unknown don't carry one in listOwnedObjects context. This is an abstraction design failure, not an unavoidable transport limitation. Code catching ObjectError from listOwnedObjects will silently miss these cases — a behavioral regression from the old ObjectError.fromResponse() path.
4. Resolver drops multi-object error diagnostics
core-resolver.ts previously collected ALL invalid objects and threw a single error with all their messages. Now it throws only the first ObjectError and silently ignores the rest. The comment admits this: "Multi-invalid aggregation is intentionally out of scope." But this makes debugging transactions with multiple missing objects materially worse (fix-one-rerun-repeat). An AggregateObjectError with .errors: ObjectError[] would preserve the new instanceof contract without the regression.
5. declare readonly transportDetails: TransportDetails is a type-level lie
This uses declare to narrow the base class's optional field to required without any runtime enforcement. The base constructor assigns this.transportDetails = options?.transportDetails — if someone passes undefined as any, it's silently undefined at runtime while TS claims it's present. The tests themselves don't trust it (they use err.transportDetails?.$kind with optional chaining). Either enforce it at runtime or stop claiming it's required.
6. Changeset claims minor but ObjectError constructor is source-breaking
The constructor changed from (code: string, message: string) to (code: ObjectErrorCode, objectId: string, options: { transportDetails: TransportDetails }). Anyone constructing ObjectError directly (including in tests/mocks) will break. GetObjectsResponse.objects narrowing from (Object | Error)[] to (Object | ObjectError)[] also changes behavior for code like instanceof Error && !(obj instanceof ObjectError). This should be major, or needs compatibility shims.
Major issues
7. GraphQL overclaims notFound for missing results
GraphQL transport synthesizes ObjectError('notFound', ...) whenever an object isn't in the result page. But absence from a GraphQL response could also mean partial indexing, authorization filtering, backend bugs, or rate limiting — not necessarily "not found." The conservative mapping would be 'unknown' unless the schema explicitly guarantees absence semantics.
8. SuiClientError is NOT actually a universal catch contract
The changeset claims "Use instanceof SuiClientError as the universal catch contract for any error originating from the client." But graphql/core.ts:806-815 throws GraphQLResponseError extends Error (not SuiClientError) and AggregateError for top-level GraphQL failures. The documented catch contract is false.
Summary
The right direction here is clear — all transports should emit ObjectError consistently. But the current implementation has too many regressions and design issues to merge. Minimum fixes needed:
- Make
transportDetailsoptional onObjectError - Add
'deleted'toObjectErrorCode - Fix
listOwnedObjectsto emitObjectErrorconsistently - Restore multi-error aggregation in the resolver
- Runtime-enforce or drop the
declarenarrowing - Mark as
majoror add compatibility shims
| code: string; | ||
| readonly code: ObjectErrorCode; | ||
| readonly objectId: string; | ||
| declare readonly transportDetails: TransportDetails; |
There was a problem hiding this comment.
critical: declare readonly transportDetails: TransportDetails narrows the base class's optional field to required without runtime enforcement. The base constructor at line 37 just does this.transportDetails = options?.transportDetails — nothing prevents undefined at runtime. Either add a runtime check in the ObjectError constructor (if (!options.transportDetails) throw ...) or stop claiming it's required.
The tests themselves don't trust this guarantee — they use err.transportDetails?.$kind with optional chaining throughout.
| } | ||
| } | ||
|
|
||
| export type ObjectErrorCode = 'notFound' | 'unknown'; |
There was a problem hiding this comment.
critical: 'notFound' | 'unknown' is too coarse. The old ObjectError preserved 'deleted' as a distinct code, which consumers use to distinguish "never existed" from "existed but was deleted." Flattening deleted into notFound means the only way to recover that info is to branch on transportDetails.$kind === 'jsonRpc' and inspect the raw payload — exactly the transport coupling Hayes said to avoid.
At minimum add 'deleted' here. If gRPC/GraphQL can't distinguish it, they just never emit that code — the union should be a superset, not a lowest-common-denominator.
| constructor( | ||
| code: ObjectErrorCode, | ||
| objectId: string, | ||
| options: { cause?: unknown; transportDetails: TransportDetails }, |
There was a problem hiding this comment.
major: Requiring transportDetails: TransportDetails (not optional) in the public constructor means any code constructing/mocking/testing ObjectError must manufacture a transport-specific tag. This leaks transport concerns into the abstraction layer, violating requirement #2. Make this optional — the transport implementations can always provide it internally.
| const transportDetails = { $kind: 'jsonRpc' as const, response: wireError }; | ||
| const extractedId = extractObjectIdFromResponseError(wireError); | ||
| if (extractedId === undefined) { | ||
| throw new SuiClientError( |
There was a problem hiding this comment.
critical: For displayError/unknown wire codes, this throws plain SuiClientError instead of ObjectError. But getObjects() at line 176 maps the same wire codes to ObjectError('unknown', batch[idx], ...) because it has access to the input object id.
This means instanceof ObjectError catches object failures from getObjects but misses them from listOwnedObjects — not unified even within the same transport. The old ObjectError.fromResponse() threw ObjectError for all wire codes.
Root cause: ObjectError now demands a non-null objectId, and displayError/unknown don't carry one here. Fix the abstraction (allow nullable objectId, or use a sentinel) rather than punting to a different error class.
| // consumer code still narrow — and `error.code`, `error.objectId`, and | ||
| // `error.transportDetails` remain recoverable. Multi-invalid aggregation is intentionally | ||
| // out of scope for this PR; if it's needed later, introduce an `AggregateObjectError`. | ||
| const firstInvalid = Array.from(responsesById.values()).find( |
There was a problem hiding this comment.
major: Previously collected ALL invalid objects and threw a single error listing them all. Now throws only the first one. For transactions referencing multiple missing objects, this turns debugging from "here are all failures" into "fix one, rerun, discover the next" — a real quality regression.
The comment acknowledges this but ships the regression anyway. If preserving instanceof ObjectError matters, add AggregateObjectError extends SuiClientError with .errors: ObjectError[].
| const normalized = normalizeSuiAddress(rawId); | ||
| return ( | ||
| page.find((obj) => obj?.address === normalized) ?? | ||
| new ObjectError('notFound', rawId, { |
There was a problem hiding this comment.
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.
| @@ -0,0 +1,33 @@ | |||
| --- | |||
| '@mysten/sui': minor | |||
There was a problem hiding this comment.
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.
|
I skimmed the most recent changes, and not all the things flagged by the review bot are valid, but the current iteration has a ton of real issues. I am not sure this is a good candidate for something to just throw at claude. It's obviously not understanding the problem space well and making a lot of incorrect assumptions. (I wouldn't just go make the changes that the review bot flagged either). I think there is a lot of subtly here that is not trivial to understand. Is this critical for a project you are working on, or just something you noticed and wanted to improve? |
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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>
7b136e0 to
a0bcc86
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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>
a0bcc86 to
1861985
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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>
1861985 to
9fca0ad
Compare
Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR MystenLabs#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>
9fca0ad to
513e032
Compare
…matic line Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Edit: Just realized I forgot to answer your question directly — this is just something I noticed and wanted to challenge myself on, not critical for any project of mine. Hey Hayes — sorry the first pass was so directionally off. I appreciate you taking the time to spell out the principles instead of just closing it. I took the five points to heart and rebuilt the PR around them. Quick tour of where things landed:
Also tightened Let me know what you think about the refactor. Happy to split into smaller PRs if the surface is too big, or iterate further on anything that doesn't match what you had in mind — and of course happy to close it out if it's still not the direction you want. |
…ional transportDetails - ObjectErrorCode now distinguishes 'deleted' from 'notFound' (JSON-RPC wire level); gRPC/GraphQL cannot distinguish, so both collapse to 'notFound'. - ObjectError's options arg is optional; the base-class transportDetails field is honestly optional (no more 'declare' narrowing) so consumers can construct ObjectError without ceremony. - AggregateObjectError extends SuiClientError — wraps multiple ObjectErrors in a single throwable, exposed via .errors, catchable via the universal contract. - grpc/core.ts: one-line note clarifying gRPC's NOT_FOUND can't distinguish deleted from never-existed (intentional collapse to 'notFound').
resolveObjectReferences previously either flattened multi-object errors to a
plain Error("invalid: \...") string or rethrew only the first. With the new
AggregateObjectError type, the resolver now:
- throws the bare ObjectError when exactly one input is invalid (so
`instanceof ObjectError` and `.code` still narrow)
- throws AggregateObjectError when >1 inputs are invalid, carrying all
ObjectErrors on .errors
Consumers that catch SuiClientError catch both cases uniformly.
GraphQLResponseError previously extended the global Error, so the multi-error
path (`throw new AggregateError(errorInstances)`) and any single-error throw
could escape `instanceof SuiClientError`. That broke the universal catch
contract for consumers switching on error type.
- GraphQLResponseError now extends SuiClientError and attaches
transportDetails: { $kind: 'graphql' }.
- handleGraphQLErrors wraps the AggregateError in a SuiClientError with
transportDetails: { $kind: 'graphql' } and the AggregateError as cause.
Also adds a one-line comment documenting that GraphQL omits absent objects
without saying why, so it cannot distinguish 'deleted' from never-existed.
…hql catch
- ObjectError: construct with no options, generates canonical messages for
'deleted' / 'notFound' / 'unknown'.
- AggregateObjectError: extends SuiClientError, exposes .errors, summarizes
child ids in the message.
- JSONRpc listOwnedObjects: 'deleted' wire code maps to ObjectError('deleted');
displayError/unknown still escalate to bare SuiClientError so ObjectError.
objectId stays non-empty.
- JSONRpc getObjects: 'deleted' wire code produces ObjectError('deleted').
- core-resolver: multi-invalid input throws AggregateObjectError with reference
identity preserved on each .errors[i].
- graphql: GraphQLResponseError is instanceof SuiClientError (universal catch).
Description
ObjectErrorwas coupled to JSON-RPC — it importedObjectResponseErrorfrom the jsonRpc layer and itsfromResponse()factory switched on JSON-RPC-specific codes. gRPC didn't use it at all, throwing plainErrorinstead. GraphQL used it but passed a baked-in message string.This PR makes
ObjectErrortransport-agnostic and satisfies each of the five principles you laid out in review:ObjectErrorCodeis the transport-agnostic union'notFound' | 'deleted' | 'unknown'. JSON-RPC distinguishesdeletedat the wire level, so it mapsdeleted→'deleted'andnotExists/dynamicFieldNotFound→'notFound';displayError/unknownsurface as'unknown'. gRPC can't distinguish deleted from never-existed (itsNOT_FOUNDis not specific), so it mapsgoogle.rpc.Code.NOT_FOUND(5) →'notFound'and everything else →'unknown'. GraphQL omits absent objects without saying why, so it also collapses to'notFound'. The'deleted'code is only surfaced where the wire genuinely distinguishes it; the other two transports stay honest.error.codeanderror.objectIdmean the same thing on all three transports. No transport-specific codes leak through the unified surface.instanceofworks consistently across all three clients. SingleObjectErrorclass inclient/errors.ts; gRPC and GraphQL now emitObjectErrorinstead of plainError.GraphQLResponseErrornow extendsSuiClientError(previously extendedError), and the GraphQL multi-error path wraps the aggregate inSuiClientErrorwithtransportDetails: { $kind: 'graphql' }on the cause — soinstanceof SuiClientErroris genuinely universal across all three transports, not just for object errors.GetObjectsResponse.objectsis narrowed from(Object | Error)[]to(Object | ObjectError)[]; existinginstanceof Errorchecks continue to work unchanged becauseObjectError extends Error.ObjectErrorlives inclient/errors.tswith zero dependencies on any transport layer. Consumers act onerror.code(three values, universal).TransportDetails, a tagged union{ $kind: 'jsonRpc' | 'grpc' | 'graphql' }on theSuiClientErrorbase class. Consumers that need the raw JSON-RPC response or thegoogle.rpc.Statusnarrow onerror.transportDetails?.$kindand get typed access to the wire payload.Implementation notes
ObjectError.objectIdis non-empty by contract. In the rare JSON-RPC case wherelistOwnedObjectsreturns an error that identifies no specific object (e.g. adisplayErrorwith noobject_id), we escalate to the baseSuiClientErrorrather than fabricating a sentinel id. Consumers who catchSuiClientErrorstill catch everything;transportDetailscarries the raw wire payload. (Chose this overobjectId: string | nullor''sentinels because an empty-string sentinel silently breaksif (error.objectId)checks, and a nullable field forces every consumer to re-handle the case.)ObjectError'soptionsarg is optional, and the base-classtransportDetailsfield is honestly optional (nodeclare readonly transportDetails: TransportDetailsnarrowing on theObjectErrorsubclass). Consumers can constructObjectErrordirectly without ceremony; the SDK still attachestransportDetailsat every real construction site.AggregateObjectError extends SuiClientError, carrying all errors on.errors.coreClientResolveTransactionPluginnow throws the bareObjectErrorwhen exactly one input is invalid (narrowing preserved) andAggregateObjectErrorwhen more than one is invalid — restoring multi-error reporting that was previously flattened to a concatenated-message plainError. Consumers that catchSuiClientErrorcatch both cases uniformly.jsonRpc/core.ts(mapJsonRpcObjectErrorCode+extractObjectIdFromResponseError) use thesatisfies neverhouse pattern fromclient/utils.ts— if the upstream wire protocol adds a newObjectResponseErrorcode, the helpers stop compiling.SuiClientError. Previouslygrpc/core.tspushednew Error(...)into the response array when the oneof was neither'object'nor'error'. That case represents a programmer error / proto drift, not per-object data, so it now throwsSuiClientError— still catchable viacatch (e) { if (e instanceof SuiClientError) ... }, but no longer surfaced as data.instanceofchecks in the composed methods now useObjectError. The narrowed(Object<Include> | ObjectError)[]slot type letsCoreClient.getObject,CoreClient.getDynamicField, andcore-resolver.tsuseinstanceof ObjectErrorinstead ofinstanceof Error— the tighter check the narrowed type earns.ObjectErrorconstruction site. Previouslygraphql/core.tspre-normalized the input ids before constructingObjectError, so a consumer passing'0x9999'sawerror.objectId === '0x000...9999'on GraphQL buterror.objectId === '0x9999'on JSON-RPC and gRPC. Now GraphQL normalizes only for the server-side lookup and preservesrawIdfor the error, matching the other two transports.SuiClientError,ObjectError,AggregateObjectError,ObjectErrorCode, andTransportDetailsare exported from@mysten/sui/client.Test plan
test/unit/client/object-error.test.ts— unit tests covering:ObjectErrorshape:instanceofchain, field access, canonical message generation for all three codes ('notFound'/'deleted'/'unknown'),causepreservation, optional-options construction,TransportDetailsfor all three$kindvariants,SimulationErrordistinguishability.AggregateObjectError:instanceof SuiClientError, exposes.errors, summarizes child ids in the aggregate message.SuiClientErrorbase:transportDetailson the base class,causepreserved independently oftransportDetails.JSONRpcCoreClient.listOwnedObjectsescalation:displayError/unknownwire codes (no wire-level object id) escalate to bareSuiClientErrorsoObjectError.objectIdstays non-empty;notExists/deleted/dynamicFieldNotFoundall produceObjectErrorwith the correct mapped code and extracted id; plus a universalcatch (SuiClientError)contract test pinning compatibility across all five wire codes.JSONRpcCoreClient.getObjectserror mapping: 5-rowit.eachexercising everyObjectResponseErrorwire code (includingdeleted→'deleted'); reference-identity test provingtransportDetails.responseround-trips the raw wire payload without copy or transformation.GrpcCoreClient.getObjectserror mapping: 4-rowit.eachexercisinggoogle.rpc.Code.NOT_FOUND(5) and three non-NOT_FOUND codes (INTERNAL=13, PERMISSION_DENIED=7, OK=0) to pin both branches of the gRPC status-code mapping.GraphQLCoreClient.getObjectserror mapping: regression tests for cross-transportobjectIdparity —ObjectError.objectIdpreserves the raw input id for both an unnormalized ('0x9999') and a pre-normalized input.test/unit/client/core-resolver.test.ts—coreClientResolveTransactionPluginsingle-error path: when exactly onetx.object(id)input is unresolved,tx.build()rethrows the originalObjectErrorinstance (reference identity) soinstanceof ObjectError/error.code/error.objectId/error.transportDetailswork consistently through the transaction resolver path.coreClientResolveTransactionPluginmulti-error path: when more than one input is invalid,tx.build()throwsAggregateObjectErrorwith every childObjectErrorpreserved by reference on.errors.handleGraphQLErrorsis catchable viainstanceof SuiClientErrorand carriestransportDetails: { $kind: 'graphql' }.test/e2e/clients/core/objects.test.ts— two newtestWithAllClientscases enforce cross-transport parity:getObjectsreturnsObjectErrorwith the correctcode,objectId, andtransportDetails.$kindon all three clients, andgetObjectthrowsObjectErroron all three clients.pnpm --filter @mysten/sui test— 380 passing, 0 failing (typecheck + unit).pnpm --filter @mysten/sui lint— 0 warnings, 0 errors; prettier clean.AI Assistance Notice