Skip to content

Schema serde: event-stream modeled exceptions thrown as plain objects #2169

Description

@kevinsanroman84

Bug Report

When using schema-based serde (@smithy/core ≥ 3.24.6), modeled exceptions on event streams are thrown as plain JavaScript objects instead of instances of the registered error class. This means instanceof checks and catch filtering by exception type are broken for any event-stream operation that uses modeled exceptions.

The HTTP error path (SmithyRpcV2CborProtocol.handleError) correctly resolves the registered error constructor via TypeRegistry.getErrorCtor and wraps the deserialized body with Object.assign(new ErrorCtor(...), ...). The event-stream path in EventStreamSerde does not — it returns the raw deserialized struct directly, which the caller then throws as-is.

Reproduction

Minimal repro (no server needed):

import { ServiceException } from "@smithy/smithy-client";
import { TypeRegistry } from "@smithy/core/schema";
import { getMessageUnmarshaller } from "@smithy/core/event-streams";

// 1. Define and register an exception (same as codegen output)
class MyStreamError extends ServiceException {
  constructor(opts) {
    super({ name: "MyStreamError", $fault: "client", ...opts });
    Object.setPrototypeOf(this, MyStreamError.prototype);
  }
}

const schema = [-3, "com.example", "MyStreamError", { error: "client", httpError: 400 }, ["message"], [0]];
TypeRegistry.for("com.example").registerError(schema, MyStreamError);

// 2. Simulate an event-stream exception wire message
const msg = {
  headers: {
    ":message-type": { type: "string", value: "exception" },
    ":exception-type": { type: "string", value: "MyStreamError" },
    ":content-type": { type: "string", value: "application/json" },
  },
  body: new TextEncoder().encode(JSON.stringify({ message: "something went wrong" })),
};

// 3. Unmarshal it
const unmarshaller = getMessageUnmarshaller(async (event) => {
  const key = Object.keys(event).find(k => k !== "__type");
  return { [key]: JSON.parse(new TextDecoder().decode(event[key].body)) };
}, (b) => new TextDecoder().decode(b));

try { await unmarshaller(msg); } catch (e) {
  console.log(e instanceof MyStreamError); // false ← BUG
  console.log(e instanceof Error);         // false ← BUG
  console.log(e);                          // { message: 'something went wrong' }
}

// Meanwhile, the registry HAS the ctor:
TypeRegistry.for("com.example").getErrorCtor(schema) === MyStreamError; // true

Expected behavior

The thrown value should be instanceof MyStreamError (and instanceof Error), matching the HTTP error path behavior.

Current behavior

The thrown value is a plain object { message: "something went wrong" } — not an Error, not instanceof any registered exception class.

Root cause

In EventStreamSerde.ts, the deserialization callback returns:

return { [unionMember]: await this.deserializer.read(eventStreamSchema, body) };

This raw struct is then thrown by getMessageUnmarshaller without ever consulting TypeRegistry.getErrorCtor to wrap it in the registered constructor.

Environment

@smithy/core: 3.24.6 (also verified against current main at 5e1e7df4b1c0e2ca5982a1a46b874bf7a68f1f3a)
Affects any generated client using schema-based serde with event-stream operations that have modeled @error members

Suggested fix

In the event-stream exception path, after deserializing the body, check if the member schema is an error schema (schema[0] === -3). If so, resolve the constructor via TypeRegistry.for(namespace).getErrorCtor(schema) and wrap with Object.assign(new ErrorCtor({message}), {$fault}, deserialized) — the same pattern used by SmithyRpcV2CborProtocol.handleError. Fall back to current behavior for unregistered/unknown exception types.

Metadata

Metadata

Assignees

Labels

bugThis issue is a bug.closing-soonThis issue will automatically close in 2 days unless further comments are made.releasedThe change has been released and there is a comment with the release version.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions