Skip to content

Generate IntoResponse for service-level errors to simplify middleware error handling #4491

@drganjoo

Description

@drganjoo

Problem

Returning Smithy-modeled errors from middleware requires a workaround. The code generator only implements IntoResponse<Protocol> for operation error enums, not for individual error shapes. Middleware is operation-agnostic and cannot use operation-specific error enums. This forces users to define dummy operations solely to generate error enums with IntoResponse, polluting the API model.

Proposed solution: Generate IntoResponse<Protocol> for error shapes defined at the service level, enabling middleware to use them directly.

Background

How smithy-rs generates error types

When you define an operation with errors in a Smithy model:

@error("client")
@httpError(404)
structure ResourceNotFound {
    @required
    message: String
}

operation GetPokemon {
    input: GetPokemonInput
    output: GetPokemonOutput
    errors: [ResourceNotFound, ValidationException]
}

The code generator produces:

  1. Individual error structures (e.g., ResourceNotFound, ValidationException)
  2. An operation error enum that wraps all possible errors for that operation:
pub enum GetPokemonError {
    ResourceNotFound(ResourceNotFound),
    ValidationException(ValidationException),
}
  1. IntoResponse<Protocol> implementation for the operation error enum:
impl IntoResponse<RestJson1> for GetPokemonError {
    fn into_response(self) -> Response<BoxBody> {
        match serialize_get_pokemon_error(&self) {
            Ok(response) => response,
            Err(e) => { /* error handling */ }
        }
    }
}

This IntoResponse implementation handles protocol-specific serialization (e.g., for RestJson1, it produces {"__type": "ResourceNotFound", "message": "..."}).

The middleware problem

Tower middleware (layers and plugins) often need to return errors before the request reaches the operation handler. For example, an authentication middleware might reject unauthorized requests.

The challenge is that middleware is operation-agnostic—it does not know which operation is being called. It cannot return GetPokemonError because the request might be for a different operation entirely.

Individual error structures like ResourceNotFound do not have IntoResponse implemented. Only the operation error enums do. This means middleware cannot directly use Smithy-modeled errors with proper protocol serialization.

Current workaround

Users define a dummy operation whose sole purpose is to generate an error enum with IntoResponse:

/// Dummy operation for middleware errors. Not a real API.
operation Middleware {
    errors: [NotAuthorized, ValidationError, InternalError]
}

This generates:

pub enum MiddlewareError {
    NotAuthorized(NotAuthorized),
    ValidationError(ValidationError),
    InternalError(InternalError),
}

impl IntoResponse<RestJson1> for MiddlewareError { /* ... */ }

Middleware can then use this enum:

impl<S> Service<Request<Body>> for AuthMiddleware<S> {
    fn call(&mut self, req: Request<Body>) -> Self::Future {
        if !is_authorized(&req) {
            return Box::pin(async {
                Ok(IntoResponse::<RestJson1>::into_response(
                    MiddlewareError::NotAuthorized(NotAuthorized { message: "...".into() })
                ))
            });
        }
        // ...
    }
}

This approach works but pollutes the API model with a fake operation

Proposed solution

Generate IntoResponse<Protocol> for individual error shapes defined at the service level:

service MyService {
    errors: [NotAuthorized, InternalError]
    operations: [GetPokemon, ListPokemon]
}

The code generator should produce IntoResponse<Protocol> for each service-level error:

impl IntoResponse<RestJson1> for NotAuthorized {
    fn into_response(self) -> Response<BoxBody> {
        match serialize_not_authorized_error(&self) {
            Ok(response) => response,
            Err(e) => { /* error handling */ }
        }
    }
}

This enables middleware to use service-level errors directly:

Ok(IntoResponse::<RestJson1>::into_response(
    NotAuthorized { message: "...".into() }
))

Implementation notes

Current code generation structure

The code generator currently produces two levels of serialization:

  1. Individual error JSON serializer (e.g., shape_resource_not_found_exception.rs):
pub fn ser_resource_not_found_exception_error(
    value: &crate::error::ResourceNotFoundException,
) -> Result<String, SerializationError> {
    // Serializes the error struct to a JSON string
}
  1. Operation error HTTP response builder (e.g., shape_get_pokemon_species.rs):
pub fn ser_get_pokemon_species_http_error(
    error: &crate::error::GetPokemonSpeciesError,
) -> Result<Response, ResponseRejection> {
    match error {
        GetPokemonSpeciesError::ResourceNotFoundException(output) => {
            let payload = ser_resource_not_found_exception_error(output)?;
            Response::builder()
                .status(404)                                    // From @httpError
                .header("x-amzn-errortype", "ResourceNotFoundException")
                .header("content-type", "application/json")
                .body(payload)?
        }
        // ... other variants
    }
}

The JSON body serialization is already factored out per error shape. However, the HTTP response construction (status code, headers) is embedded in each operation's error serializer function.

Required changes

To generate IntoResponse for individual error structs, the code generator needs to also factor out the HTTP response construction for each error shape:

// New: Per-error HTTP response builder
fn ser_resource_not_found_exception_http_response(
    error: &ResourceNotFoundException,
) -> Result<Response, ResponseRejection> {
    let payload = ser_resource_not_found_exception_error(error)?;
    Response::builder()
        .status(404)
        .header("x-amzn-errortype", "ResourceNotFoundException")
        .header("content-type", "application/json")
        .body(payload)
}

// Individual error struct now has IntoResponse
impl IntoResponse<RestJson1> for ResourceNotFoundException {
    fn into_response(self) -> Response<BoxBody> {
        ser_resource_not_found_exception_http_response(&self)
            .unwrap_or_else(|e| /* error fallback */)
    }
}

// Operation error enum delegates to the same function
pub fn ser_get_pokemon_species_http_error(
    error: &GetPokemonSpeciesError,
) -> Result<Response, ResponseRejection> {
    match error {
        GetPokemonSpeciesError::ResourceNotFoundException(inner) => {
            ser_resource_not_found_exception_http_response(inner)
        }
        // ... other variants
    }
}

This ensures both code paths use the same serialization logic, avoiding duplication.

Benefits

  • Eliminates the need for dummy operations
  • Provides a clear, documented pattern for middleware error handling
  • Aligns with the semantic purpose of service-level errors (errors common to all operations)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions