Skip to content

Allow ClientContext.Custom unmarshaling for non-string (JSON) values#620

Open
M-Elsaeed wants to merge 2 commits intomainfrom
moehabe/supportjsonpayloads
Open

Allow ClientContext.Custom unmarshaling for non-string (JSON) values#620
M-Elsaeed wants to merge 2 commits intomainfrom
moehabe/supportjsonpayloads

Conversation

@M-Elsaeed
Copy link

Issue #, if available: -

Description of changes:

When AWS services like Bedrock Agentcore Gateway send nested JSON objects as values in ClientContext.custom (e.g. propagated headers), the Go Lambda runtime fails with: cannot unmarshal object into Go struct field ClientContext.custom of type string. This blocks all Go Lambda functions used with Agentcore Gateway propagated headers.

Root cause
ClientContext.Custom is map[string]string, which rejects non-string JSON values during json.Unmarshal in parseClientContext() before the handler is ever invoked.

How this Fixes that
Add a custom UnmarshalJSON to ClientContext that parses Custom values via json.RawMessage. String values are stored directly (backward compatible) while Non-string values (objects, arrays) are serialized to their JSON string representation instead of failing.

Fully backward compatible - no struct signature changes. All existing tests pass.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov-commenter
Copy link

codecov-commenter commented Mar 4, 2026

Codecov Report

❌ Patch coverage is 0% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.99%. Comparing base (9c32960) to head (3de2206).

Files with missing lines Patch % Lines
lambdacontext/context.go 0.00% 18 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #620      +/-   ##
==========================================
- Coverage   74.94%   73.99%   -0.96%     
==========================================
  Files          36       36              
  Lines        1401     1419      +18     
==========================================
  Hits         1050     1050              
- Misses        273      291      +18     
  Partials       78       78              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

if err := json.Unmarshal(v, &s); err == nil {
cc.Custom[k] = s
} else {
cc.Custom[k] = string(v)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can add a test when JSON parsing is failing? (I think a non-valid JSON will trigger that code path?)

// This handles the case where values in the "custom" map are not strings
// (e.g. nested JSON objects), by serializing non-string values back to
// their JSON string representation.
func (cc *ClientContext) UnmarshalJSON(data []byte) error {
Copy link
Collaborator

@bmoffatt bmoffatt Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking there may need to be a MarshalJSON too, so that a re-serialization produces the same output as what was input. (number, bool values. quote escaping in nested json)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see: https://go.dev/play/p/8q6bgWa-Rcw where re marshaling results in a type change

// You can edit this code!
// Click here and start typing.
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"log/slog"
	"os"
)

type X struct {
	Custom map[string]string
}

func (x *X) UnmarshalJSON(b []byte) error {
	var m struct{ Custom map[string]json.RawMessage }
	if err := json.Unmarshal(b, &m); err != nil {
		return err
	}
	x.Custom = make(map[string]string, len(m.Custom))
	for k, v := range m.Custom {
		var s string
		if err := json.Unmarshal(v, &s); err != nil {
			slog.Warn("uhoh", "err", err, "key", k, "val", v)
			s = string(v)
		}
		x.Custom[k] = s
	}
	return nil
}

func main() {
	input := []byte(`{"Custom":{"hello":9001}}`)
	fmt.Println("Input:")
	os.Stdout.Write(input)
	fmt.Println("")

	var x X
	if err := json.Unmarshal(input, &x); err != nil {
		log.Fatal(err)
	}

	output, err := json.Marshal(x)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Output:")
	os.Stdout.Write(output)
}
Input:
{"Custom":{"hello":9001}}
2009/11/10 23:00:00 WARN uhoh err="json: cannot unmarshal number into Go value of type string" key=hello val="9001"
Output:
{"Custom":{"hello":"9001"}}
Program exited.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting. Java and .NET already be mangling this with string conversions for numbers and bools. And .NET only will stringify nested values.


Client Context custom — Return Value Test Matrix

All functions return context.clientContext.custom as JSON.

Test 1: {"custom": {"number": 9001, "bool": false}}

Runtime Result
Java {"number":"9001","bool":"false"}
.NET {"number":"9001","bool":"False"}
Rust ❌ Runtime crash (logs: invalid type: integer '9001', expected a string)
Go cannot unmarshal number into Go struct field ClientContext.custom of type string

Test 2: {"custom": {"nested": {"hello": "world"}}}

Runtime Result
Java Expected a string but was BEGIN_OBJECT
.NET {"nested":"{\"hello\":\"world\"}"}
Rust ❌ Runtime crash (logs: invalid type: map, expected a string)
Go cannot unmarshal object into Go struct field ClientContext.custom of type string

@bmoffatt
Copy link
Collaborator

bmoffatt commented Mar 5, 2026

How does this behave in the other ric libraries with typed de-serialization? (Java, .NET, Rust) #620 (comment)

@bmoffatt
Copy link
Collaborator

bmoffatt commented Mar 5, 2026

mm yeah java don't like nested either

aws lambda invoke ... --client-context "$(echo -n '{"custom":{"hello":"world","nested":{"yolo":"hello"},"number":22}}' | base64)" /dev/stdout 2>&1
{
  "errorMessage": "java.lang.IllegalStateException: Expected a string but was BEGIN_OBJECT at line 1 column 38 path $.custom.",
  "errorType": "com.amazonaws.lambda.thirdparty.com.google.gson.JsonSyntaxException",
  "stackTrace": [
    "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$Adapter.read(ReflectiveTypeAdapterFactory.java:397)",
    "com.amazonaws.lambda.thirdparty.com.google.gson.TypeAdapter$1.read(TypeAdapter.java:204)",
    "com.amazonaws.services.lambda.runtime.serialization.factories.GsonFactory$InternalSerializer.fromJson(GsonFactory.java:75)",
    "com.amazonaws.services.lambda.runtime.serialization.factories.GsonFactory$InternalSerializer.fromJson(GsonFactory.java:94)"
  ],
  "cause": {
    "errorMessage": "Expected a string but was BEGIN_OBJECT at line 1 column 38 path $.custom.",
    "errorType": "java.lang.IllegalStateException",
    "stackTrace": [
      "com.amazonaws.lambda.thirdparty.com.google.gson.stream.JsonReader.nextString(JsonReader.java:836)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.TypeAdapters$15.read(TypeAdapters.java:421)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.TypeAdapters$15.read(TypeAdapters.java:409)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.TypeAdapterRuntimeTypeWrapper.read(TypeAdapterRuntimeTypeWrapper.java:40)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.MapTypeAdapterFactory$Adapter.read(MapTypeAdapterFactory.java:186)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.MapTypeAdapterFactory$Adapter.read(MapTypeAdapterFactory.java:144)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$1.readIntoField(ReflectiveTypeAdapterFactory.java:212)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$FieldReflectionAdapter.readField(ReflectiveTypeAdapterFactory.java:433)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$Adapter.read(ReflectiveTypeAdapterFactory.java:393)",
      "com.amazonaws.lambda.thirdparty.com.google.gson.TypeAdapter$1.read(TypeAdapter.java:204)",
      "com.amazonaws.services.lambda.runtime.serialization.factories.GsonFactory$InternalSerializer.fromJson(GsonFactory.java:75)",
      "com.amazonaws.services.lambda.runtime.serialization.factories.GsonFactory$InternalSerializer.fromJson(GsonFactory.java:94)"
    ]
  }
}

var s string
if err := json.Unmarshal(v, &s); err == nil {
cc.Custom[k] = s
} else {
Copy link
Collaborator

@bmoffatt bmoffatt Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the nested json in the Client Context like, critical to all use of the Bedrock Agentcore Gateway service? Rather than stringifying, another option is to drop non-string values.

var raw struct { Custom map[string]any }
json.Unmarshal(b, &raw)
for k, v := range raw.Custom {
    if s, ok := v.(string); ok {
        cc.Custom[k] = s
    }
}

Bringing this option up, so as not to be hasty in type coercion decisions that could introduce inconsistencies with other runtimes.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on why you would drop types other than a string? It seems possible that this would bite you down the road if another service was enabled that needed this but had values other than strings.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Balancing urgency in (partially?) unblocking a use-case, and introducing warts that'd be hard to undo without semver bumps. Other runtimes have the same blocker, and it'd be prudent to line up a similar strategy for supporting the usecase.


For Go, other options that solve for making the context parse more lenient, without committing to inconsistent type coercion for this novel use of client context:

  • Assign LambdaContext.ClientContext.Custom to nil if parse fails, slog.Warn the error.
  • Move the LambdaContext parse step from pre-invoke to being done lazily only when handlers fetch it using lambdacontext.FromContext(ctx) (and logging errors, returning nil, false)

An option to make the novel use of Client Context readable in a way consistent with node, python, (eg: no type coercion) could be something like stuffing the []byte from the client context header into the context.Context pre-invoke. Combine with swallowing the parse error (set Custom to nil), and a deprecation comment on the ClientContext.Custom field // Deprecated: Custom is 'nil' when client sends something other than a map[string]string. Use `lambdacontext.ClientContextCustomFromContext` instead

and adding new function

ClientContextCustomFromContext[T](ctx context.Context) (*T, error) {
  b, ok := ctx.Value("lambda-client-context-header-value").([]byte)
  if !ok {
      return nil errors.New("ClientContext not found in context)
  }
  var t T
  if err := json.Unmarshal(lc.ClientContext.CustomRaw, &t); err != nil {
      return nil, err  
  }
  return &t, nil
}

^ is my general recommendation to keep back compat while also unblocking the new use-case. But given that .NET and Java are already inconsistent this isn't necessarily the only solution.

// their JSON string representation.
func (cc *ClientContext) UnmarshalJSON(data []byte) error {
var raw struct {
Client ClientApplication `json:"Client"`
Copy link
Collaborator

@bmoffatt bmoffatt Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo, should be "client". Well actually, no struct tag. without the json: struct tag, the current ClientContext is lenient to both "Custom" and "custom". The mobile SDKs I belive all strictly send "custom", and .NET, java, Rust, all drop "Custom", so IDK why this was left lenient 🤷‍♂️ - but I guess should also stay lenient for back compat

nevermind

When parsing a JSON object into a Go struct, keys are considered in a case-insensitive fashion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants