API Gateway: Explaining Lambda Payload version 2.0 in HTTP API

Jaewoo Ahn
8 min readMar 15, 2020

--

HTTP API has been GA on Mar 12. The blog described new features and changes including Lambda payload version 2.0 which would be used by default since GA. Although HTTP API provides a way to use 1.0 version of Lambda payload, changing the default introduced breaking changes from beta.

In this article, I’ll provide a certain level of explanation why and how it has been changed. The change has been made based on the lessons learned from previous experiences, heard a lot of feedbacks, and contemplated this. In short, it wasn’t changed just for an aesthetic reason.

Lesson learned from REST API

Before HTTP API, API Gateway provided 2 types of APIs: REST API and WebSocket API. REST API is the longest-lived one since 2015 and most popular. It also means that it had more known issues as a nature of the first product. Some of them has been addressed in either of good ways and bad ways. Sometimes, the solution looked good at the time when the decision is made, but later, it might not be in long-term perspective.

For example, REST API had not supported multi-value headers and query string parameters until they were allowed in 2018. To explain what the change was, let’s imagine a following request:

curl https://{hostname}/{path}?key=val&key=val2&key2=val3 \
-H 'FOO: bar0' \
-H 'foo: bar1' \
-H 'foo: bar2' \
-H 'foo2: bar3,bar4'

It looks silly to you, but mixing case(FOO, foo) had been one of the workarounds to send multi-value parameters in the past. HTTP header MUST be case-insensitive, but REST API has treated them as case-sensitive(NOTE: this is still a known issue in REST API. HTTP API has solved this).

Before the multi-value parameter was supported, the Lambda payload had looked like (removed other fields for clarification):

Lambda payload before the multi-value parameter support

You could see the first value of foo and key was not included in the payload. API Gateway put the last value of each parameter and discarded everything else.

After the multi-value support, now the Lambda payload looks:

Lambda payload after the multi-value parameter support

It added a new pair of properties called multiValueHeaders and multiValueQueryStringParameters that is essentially a copy of headers/queryStringParameters but contains an array of strings instead of a single string.

It may be not a big deal for you, but let’s see how you can access each parameter. Does it look good to you?

Although it allowed to use multi-value parameters without causing any breaking change for existing customer, there are some undesirable behaviors in this solution:

  • The size of the payload is now bigger because all the headers exist in both “headers” and “multiValueHeaders”.
  • It is s non-intuitive that the existing “header” property itself doesn’t have all the values. To get all the values, you need to look in the “multiValueHeader” property, still dealing with comma-concatenated value(s).
  • Header case-sensitivity issue was not addressed.

Lesson learned from WebSocket API

In December 2018, WebSocket API was launched. WebSocket API was the first non-HTTP protocol (although it is on top of HTTP protocol) in API Gateway. At the time, a lot of things within API Gateway had been targeted to REST API. For example, “resource” or “method” does not work well with WebSocket message. This is the reason API Gateway V2 model has been introduced and switched from “Resource” to “Route”.

The top-level fields in the Lambda payload didn’t require much change. The handshake request ($connect) is a pure HTTP request which does not require any change. The message route and $disconnect aren’t HTTP requests but non-compatible fields (e.g. resource and method) can be omitted.

But what about requestContext? WebSocket API requires a different set of fields from REST API. It ended up to omit some REST related fields while WebSocket related fields had been added into requestContext. For JSON schema perspective, they’re actually different models.

This brought questions to us. How can different protocols can be embraced within a unified model? What about grouping the related fields instead of making a lot of “optional” fields and let the consumer to figure out?

Lesson learned from Lambda Proxy Response

I bet almost everyone experienced “Malformed Lambda Response” with 502 when they attempted to use Lambda proxy integration for the first time. You have tested your Lambda function in the Lambda console and everything works perfectly. But why API Gateway throws 502 when I tried to invoke it via Lambda proxy integration? You finally have to figure out that API Gateway requires a specific JSON output format for the response.

When people writes a business logic function, they perform a logic, then just want to return a result. It is perfectly fine within the function, but you have to consider the function returns a result to the remote caller which requires the serialization and data formatting. It is true between API Gateway and Lambda as well.

Additionally, when something is wrong, an exception would be thrown (some language doesn’t have exception though). If it is uncaught, the exception would be turned into 5xx errors any way. If there is nothing to do with the exception, catching exception and returning 5xx is not much worth to do.

In this context, we could blame the developer who didn’t read the API Gateway Lambda Proxy output format documentation. Well, I did a same mistake when I used Lambda Proxy but I didn’t feel comfortable to blame myself though. Can’t API Gateway provide a better developer experience?

Lambda Proxy and its payload

A HTTP request needs to be transformed into a payload which can be sent as an argument to a Lambda function, currently it is JSON. The payload must contain the semantics of HTTP and additional context information given by API Gateway.

With this context, Lambda Proxy is not HTTP proxy but it should do its best effort to imitate the behavior of HTTP proxy as much as possible. The previous Lambda payload did certain level of imitations, but there were known issues to be addressed and many lessons has been learned.

Multi-value headers in Lambda payload 2.0

Having both headers and multiValueHeaders in the payload is not ideal. It is more intuitive and easier to use when all values are available through “headers”.

To present headers in JSON, headers would be JSON object. Apparently the key would be the header field name, but there are several options to expose multiple header values in the value:

  • Array or string
  • Always array
  • String (comma-concatenated)

Array or string is using Array when it is multi-value while using string when it is a single value. In theory, it sounds correct, but practically it is harder for both API Gateway and consumer. API Gateway has to serialize differently whether it is multi-value or not, and the consumer also need to check whether it is array or not.

Always array is simple for API Gateway, but not ideal for the consumer who writes a Lambda function. You have to use input.header.foo[0] instead of input.header.foo even you only have a single header. You also need to deal with null or indexOutOfRange when the header is not available.

String (comma-concatenated) is most simple for both and it does not change any semantics of the message as mentioned in RFC 7230.

"A recipient MAY combine multiple header fields with the same field name into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field value to the combined field value in order, separated by a comma.  The order in which header fields with the same field name are received is therefore significant to the interpretation of the combined field
value; a proxy MUST NOT change the order of these field values when forwarding a message."

This was chosen in Lambda payload 2.0. As a result, you have a single string whether the value are multiple or not. Also there is no different between duplicated headers(foo) and a single header(foo2) contains a concatenated values. You might also noticed the case-sensitivity issue was gone, now all headers will be lower-cased.

Cookies in Lambda payload 2.0

There is one problem with comma-concatenation in header: Cookie and Set-Cookie. They are exceptional header which should be concatenated with semi-colon instead. Even Set-Cookie allows to use comma within the value and the directive should be applied to the specific cooke element within the same line. If “cookie” or “set-cookie” header within “headers” in the payload, it does not work well with other headers.

In 2.0, the payload has a separate key named “cookie” which contains an array. For the request, each element in array corresponds with the value of “cookie” header. Each cookie will be individual element, whether they are sent as multiple “cookie” headers or semi-colon concatenated within a single “cookie” header.

If the request has following cookie headers:

The payload will have:

When the Lambda function returns like this:

The response will have following set-cookie headers:

Query String in Lambda payload 2.0

Unlike headers, the RFC specification doesn’t mention how to handle duplicated query parameters. Depends on server’s implementation, query1=value1&query1=value2 could be:

  • First-win : “value1”
  • Last-win : “value2”
  • Array : [ “value1”, “value2” ]
  • Comma-concatenated : “value1,value2”

Lambda payload 2.0 concatenate multi-value query string parameter values with comma. It is consistent along with headers’s behavior and is more intuitive.

However, there are few more cases to consider in the query string.
First, what if the value itself contains comma?
query1=value1&query2=foo,bar&query1=value2
A comma(,) is a reserved character in the RFC, but it is not clear when comma is used within the query string parameter value. Some people might use it as a multi-value same as headers, but other might use it in the different semantic. The comma concatenation will cause a problem for the latter case.

Second, what if the customer are very sensitive for the order of query string?
For example, query1=value1&query2=foo,bar&query1=value2 will do:

  • DoActionOnQuery1(“value1”)
  • DoActionOnQuery2(“foo,bar”)
  • DoActionOnQuery1(“value2”)

While query1=value1&query1=value2&query2=foo,bar might cause a different result:

  • DoActionOnQuery1(“value1”)
  • DoActionOnQuery1(“value2”)
  • DoActionOnQuery2(“foo,bar”)

For those cases, Lambda payload 2.0 will have rawQueryString for those customer to parse the query string as they want. So the query1=value1&query2=foo,bar&query1=value2 would be:

RequestContext in Lambda payload 2.0

As explained before, requestContext has many top-level optional fields depends on the protocol and route type:

  • REST API
  • WebSocket API $connect
  • WebSocket API other routes (message, $disconnect)

Additionally, which the authorizer are used, all fields under “authorizer” were optional and conditional. Here is a rough view of them:

Now, Lambda payload 2.0 has following groups and put them into hierarchical structure. When the protocol is “http” and the authorizer is “jwt”:

When the protocol is “websocket” and the authorizer is “lambda”, it would be:
(NOTE: This is an illustration purpose. WebSocket API does not support Lambda Payload 2.0 and Lambda authorizer is not supported in HTTP API yet)

Simplified Lambda Proxy response in Lambda Proxy 2.0

In Lambda proxy 2.0, if you want, you just can return any JSON without the strict formatting.

This is equivalent to this. API Gateway infers that you’re trying to return “200” with JSON payload, then put the JSON into the body.

Remember, you can still use the previous response output format to customize status code, header, or isBase64Encoded.

Why 2.0 become default in GA?

Lambda payload version 2.0 includes the lessons learned and improvements. This should have been a default format in HTTP API. Unfortunately, this wasn’t included in Beta features.

Choosing a default is very hard decision but generally it should be the suitable for the most of the case since people tend to use it without make any change. If 2.0 format was being introduced to existing REST API which has been running in production since 2015, it would never be default. New features must be opt-in without breaking existing customers.

In contrast, HTTP API is a brand-new service. So the risks has been taken to make a breaking change since there was a strong belief on doing a right thing in the long-term value. If 1.0 format is kept as default, more people will be in production without customizing it to 2.0.

Nevertheless, those breaking changes should been communicated well with the people who has used HTTP API Beta. I personally apologize any inconvenience caused by this change.

Still don’t favor 2.0 format?

I hope that the rationale behind the change has been explained enough, but please free to leave a comment if you have any doubt or if you still don’t like 2.0 format.

--

--