Skip to content

Handler design for streaming responses #500

@aidansteele

Description

@aidansteele

AWS recently released support for streaming responses from Lambda functions. I can see that work is currently underway to add this to the Go SDK in #494, #495. It might be too late, but I'd like to raise an alternative API design for your consideration.

Is your feature request related to a problem? Please describe.
Right now it looks like streaming responses will be supported by having a handler function return an io.Reader. I think that this could potentially make the implementation of streaming-response Lambda functions more error-prone and provide less control over the streamed output than using an io.Writer.

My understanding is that a streaming response handler ~today should be implemented like this:

func handleReturningReader(ctx context.Context, input json.RawMessage) (io.Reader, error) {
	pr, pw := io.Pipe()

	go func() {
		// if this function panics, the entire process crashes. it can't be caught by
		// callBytesHandlerFunc like a panic in handleReturningReader would be. i suspect
		// a lot of users will be affected by this.

		for idx := 0; idx < 10; idx++ {
			fmt.Fprintln(pw, "hello world!")
			time.Sleep(time.Second)
		}
		
		if err := doSomething(); err != nil {
			// all mid-stream errors need to be funneled through the pipe. that's not
			// the end of the world, but how many users will know how to do this? it's
			// not as convenient as returning an error
			pw.CloseWithError(err)
		}

		// finally, if the user forgets to close the pipe (e.g. maybe they returned early)
		// then the invocation will just hang until it times out. the docs can advise
		// that the pipe must always be closed, but it's another opportunity for error
		pw.Close()
	}()

	return pr, nil
}

Describe the solution you'd like
Consider a handler signature like this:

func handleReceivingWriter(ctx context.Context, output io.Writer, input json.RawMessage) error {
	// any panics in this function will be caught by the invoke loop and turned into an error
	// response like usual

	for idx := 0; idx < 10; idx++ {
		// the user has control over the size of each chunk in the chunked transfer-encoding.
		// this can simplify the usage of InvokeWithResponseStream on the client side
		fmt.Fprintln(output, "hello world!")
		time.Sleep(time.Second)
	}

	if err := doSomething(); err != nil {
		// error handling is the normal if err != nil { return nil } convention
		return err
	}

	return nil
}

This handler signature avoids the necessity of spawning a goroutine and recovering from panics in it. It allows for standard error handling conventions. It also allows the user to have control over the size of the response chunks.

This potential design doesn't address how Lambda function URL response streaming would work. The application/vnd.awslambda.http-integration-response content-type doesn't appear to documented yet, but I've figured out how it works from #494. I can think of two options.

The first option is that there can be a lambda.WriteResponseHeaders(writer io.Writer, statusCode int, headers map[string]string, cookies []string) helper function that the user calls to write the prelude before streaming their response body. This would suffer from the issue of being non-obvious and users would have to read the docs to know that they need to use it.

The second option is that we can copy the design of stdlib http.Handler and instead of a plain io.Writer, we pass in a lambda.StreamingResponseWriter, e.g. like so:

// this just so happens to be identical to the http.ResponseWriter interface
type StreamingResponseWriter interface {
	Header() http.Header
	Write([]byte) (int, error)
	WriteHeader(statusCode int)
}

func handleReceivingResponseWriter(ctx context.Context, w lambda.StreamingResponseWriter, input json.RawMessage) error {
	w.WriteHeader(200)
	w.Header().Set("Content-Type", "text/plain")
	w.Header().Set("My-Header", "my value")
	
	// this works because lambda.StreamingResponseWriter matches http.ResponseWriter
	http.SetCookie(w, &http.Cookie{Name: "cookie", Value: "val", Path: "/my-path"})

	for idx := 0; idx < 10; idx++ {
		fmt.Fprintln(w, "hello world!")
		time.Sleep(time.Second)
	}

	if err := doSomething(); err != nil {
		// error handling is the normal if err != nil { return nil } convention
		return err
	}

	return nil
}

I haven't fully thought through this second option yet. I think there's definitely value in the familiarity aspect - most Go developers will recognise the http.Handler-like signature and know how to use it. An outstanding question on my mind is "do we need to support non-function URL streaming responses?" i.e. does there need to be a way to set the "top-level" content-type and not just a content-type within the function URL response "prelude". But it seems like the ability to set that content-type doesn't yet exist today anyway so maybe it's not necessary 🤔

Additional context
I noticed that the Lambda-Runtime-Function-Response-Mode: streaming request header isn't set anywhere yet. Is it not needed?

I'm also aware that supporting a second handler type might be a bigger undertaking than squeezing support in via an io.Reader return type. I just wanted to mention the alternatives to see if they had been considered yet.

Supporting two different handler signatures might seem to over-complicate the package, but I also suppose it would match the Node.js library introducing the awslambda.streamifyResponse decorator and inner function with three parameters.

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