RFC: Errors for event streams

Status: Implemented

For a summarized list of proposed changes, see the Changes Checklist section.

This RFC defines how client and server will use errors defined in @streaming unions (event streams).

The user experience if this RFC is implemented

In the current version of smithy-rs, customers who want to use errors in event streams need to use them as so:

stream! {
    yield Ok(EventStreamUnion::ErrorVariant ...)
}

Furthermore, there is no support for @errors in event streams being terminal; that is, when an error is sent, it does not signal termination and thus does not complete the stream.

This RFC proposes to make changes to:

  • terminate the stream upon receiving a modeled error
  • change the API so that customers will write their business logic in a more Rust-like experience:
stream! {
    yield Err(EventStreamUnionError::ErrorKind ...)
}

Thus any Err(_) from the stream is terminal, rather than any Ok(x) with x being matched against the set of modeled variant errors in the union.

How to actually implement this RFC

In order to implement this feature:

  • Errors modeled in streaming unions are going to be treated like operation errors
    • They are in the error:: namespace
    • They have the same methods operation errors have (name on the server, metadata on the client and so on)
    • They are not variants in the corresponding error structure
  • Errors need to be marshalled and unmarshalled
  • Receiver must treat any error coming from the other end as terminal

The code examples below have been generated using the following model:

@http(uri: "/capture-pokemon-event/{region}", method: "POST")
operation CapturePokemonOperation {
    input: CapturePokemonOperationEventsInput,
    output: CapturePokemonOperationEventsOutput,
    errors: [UnsupportedRegionError, ThrottlingError]
}

@input
structure CapturePokemonOperationEventsInput {
    @httpPayload
    events: AttemptCapturingPokemonEvent,

    @httpLabel
    @required
    region: String,
}

@output
structure CapturePokemonOperationEventsOutput {
    @httpPayload
    events: CapturePokemonEvents,
}

@streaming
union AttemptCapturingPokemonEvent {
    event: CapturingEvent,
    masterball_unsuccessful: MasterBallUnsuccessful,
}

structure CapturingEvent {
    @eventPayload
    payload: CapturingPayload,
}

structure CapturingPayload {
    name: String,
    pokeball: String,
}

@streaming
union CapturePokemonEvents {
    event: CaptureEvent,
    invalid_pokeball: InvalidPokeballError,
    throttlingError: ThrottlingError,
}

structure CaptureEvent {
    @eventHeader
    name: String,
    @eventHeader
    captured: Boolean,
    @eventHeader
    shiny: Boolean,
    @eventPayload
    pokedex_update: Blob,
}

@error("server")
structure UnsupportedRegionError {
    @required
    region: String,
}
@error("client")
structure InvalidPokeballError {
    @required
    pokeball: String,
}
@error("server")
structure MasterBallUnsuccessful {
    @required
    message: String,
}
@error("client")
structure ThrottlingError {}

Wherever irrelevant, documentation and other lines are stripped out from the code examples below.

Errors in streaming unions

The error in AttemptCapturingPokemonEvent is modeled as follows.

On the client,

pub struct AttemptCapturingPokemonEventError {
    pub kind: AttemptCapturingPokemonEventErrorKind,
    pub(crate) meta: aws_smithy_types::Error,
}
pub enum AttemptCapturingPokemonEventErrorKind {
    MasterBallUnsuccessful(crate::error::MasterBallUnsuccessful),
    Unhandled(Box<dyn std::error::Error + Send + Sync + 'static>),
}

On the server,

pub enum AttemptCapturingPokemonEventError {
    MasterBallUnsuccessful(crate::error::MasterBallUnsuccessful),
}

Both are modeled as normal errors, where the name comes from Error with a prefix of the union's name. In fact, both the client and server generate operation errors and event stream errors the same way.

Event stream errors have their own marshaller. To make it work for users to stream errors, EventStreamSender<>, in addition to the union type T, takes an error type E; that is, the AttemptCapturingPokemonEventError in the example. This means that an error from the stream is marshalled and sent as a data structure similarly to the union's non-error members.

On the other side, the Receiver<> needs to terminate the stream upon receiving any error. A terminated stream has no more data and will always be a bug to use it.

An example of how errors can be used on clients, extracted from this test:

yield Err(AttemptCapturingPokemonEventError::new(
    AttemptCapturingPokemonEventErrorKind::MasterBallUnsuccessful(MasterBallUnsuccessful::builder().build()),
    Default::default()
));

Because unions can be used in input or output of more than one operation, errors must be generated once as they are in the error:: namespace.

Changes checklist

  • Errors are in the error:: namespace and created as operation errors
  • Errors can be sent to the stream
  • Errors terminate the stream
  • Customers' experience using errors mirrors the Rust way: Err(error::StreamingError ...)