Skip to content

Commit ea3002c

Browse files
authored
Merge branch 'main' into feat(router)--enable-fail-open-on-rate-limit-redis-unavailability
2 parents ce3bb3c + a692cf6 commit ea3002c

File tree

6 files changed

+104
-15
lines changed

6 files changed

+104
-15
lines changed

router-tests/error_handling_test.go

+64
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package integration
33
import (
44
"cmp"
55
"encoding/json"
6+
"github.com/wundergraph/cosmo/router/core"
67
"net/http"
78
"slices"
89
"testing"
@@ -1102,4 +1103,67 @@ func TestErrorPropagation(t *testing.T) {
11021103
require.Equal(t, `{"errors":[{"message":"Unauthorized","locations":[{"line":1,"column":1}],"extensions":{"code":"UNAUTHORIZED"}}],"data":{"employees":null}}`, res.Body)
11031104
})
11041105
})
1106+
1107+
t.Run("validate error when a non subscription multipart is printed", func(t *testing.T) {
1108+
t.Parallel()
1109+
1110+
testenv.Run(t, &testenv.Config{
1111+
NoRetryClient: true,
1112+
RouterOptions: []core.Option{
1113+
core.WithEngineExecutionConfig(config.EngineExecutionConfiguration{
1114+
EnableNetPoll: true,
1115+
EnableSingleFlight: true,
1116+
MaxConcurrentResolvers: 1,
1117+
}),
1118+
core.WithSubgraphRetryOptions(false, 0, 0, 0),
1119+
},
1120+
}, func(t *testing.T, xEnv *testenv.Environment) {
1121+
resp, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{
1122+
Header: map[string][]string{
1123+
"service-name": {"service-name"},
1124+
"accept": {"multipart/mixed;deferSpec=20220824"},
1125+
},
1126+
Query: `query employees { employees { ide } }`, // Missing closing bracket
1127+
})
1128+
1129+
expected := "--graphql\r\n" +
1130+
"Content-Type: application/json\r\n" +
1131+
"\r\n" +
1132+
"{\"errors\":[{\"message\":\"field: ide not defined on type: Employee\",\"path\":[\"query\",\"employees\"]}]}\r\n" +
1133+
"--graphql--"
1134+
require.Equal(t, expected, resp.Body)
1135+
require.NoError(t, err)
1136+
})
1137+
})
1138+
1139+
t.Run("validate the error format when a subscription multipart is printed", func(t *testing.T) {
1140+
t.Parallel()
1141+
1142+
testenv.Run(t, &testenv.Config{
1143+
NoRetryClient: true,
1144+
RouterOptions: []core.Option{
1145+
core.WithEngineExecutionConfig(config.EngineExecutionConfiguration{
1146+
EnableNetPoll: true,
1147+
EnableSingleFlight: true,
1148+
MaxConcurrentResolvers: 1,
1149+
}),
1150+
core.WithSubgraphRetryOptions(false, 0, 0, 0),
1151+
},
1152+
}, func(t *testing.T, xEnv *testenv.Environment) {
1153+
resp, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{
1154+
Header: map[string][]string{
1155+
"service-name": {"service-name"},
1156+
"accept": {"multipart/mixed;deferSpec=20220824"},
1157+
},
1158+
Query: `subscription employees { employees { ide } }`, // Missing closing bracket
1159+
})
1160+
1161+
expected := "--graphql\r\n" +
1162+
"Content-Type: application/json\r\n" +
1163+
"\r\n" +
1164+
"{\"payload\":{\"errors\":[{\"message\":\"field: employees not defined on type: Subscription\",\"path\":[\"subscription\"]}]}}"
1165+
require.Equal(t, expected, resp.Body)
1166+
require.NoError(t, err)
1167+
})
1168+
})
11051169
}

router-tests/events/nats_events_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ func TestNatsEvents(t *testing.T) {
534534

535535
// Read the first part
536536

537-
expected := "\r\n--graphql\nContent-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"countFor\":0}}}\n"
537+
expected := "\r\n--graphql\r\nContent-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"countFor\":0}}}\n"
538538
read := make([]byte, len(expected))
539539
_, err = reader.Read(read)
540540
assert.NoError(t, err)
@@ -583,7 +583,7 @@ func TestNatsEvents(t *testing.T) {
583583

584584
// Read the first part
585585

586-
expected := "\r\n--graphql\nContent-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"countFor\":0}}}\n\r\n--graphql"
586+
expected := "\r\n--graphql\r\nContent-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"countFor\":0}}}\n\r\n--graphql"
587587
read := make([]byte, len(expected))
588588
_, err = reader.Read(read)
589589
assert.NoError(t, err)

router/CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Binaries are attached to the github release otherwise all images can be found [h
44
All notable changes to this project will be documented in this file.
55
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
66

7+
## [0.189.1](https://github.com/wundergraph/cosmo/compare/[email protected]@0.189.1) (2025-03-10)
8+
9+
### Bug Fixes
10+
11+
* resolve multipart not working properly for some clients ([#1650](https://github.com/wundergraph/cosmo/issues/1650)) ([8b2d35a](https://github.com/wundergraph/cosmo/commit/8b2d35a72022957e13bf158092ba32d58b757623)) (@SkArchon)
12+
713
# [0.189.0](https://github.com/wundergraph/cosmo/compare/[email protected]@0.189.0) (2025-03-07)
814

915
### Features

router/core/errors.go

+19-4
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,10 @@ func writeRequestErrors(r *http.Request, w http.ResponseWriter, statusCode int,
212212
return
213213
}
214214
} else if wgRequestParams.UseMultipart {
215+
requestContext := getRequestContext(r.Context())
216+
215217
// Handle multipart error response
216-
if err := writeMultipartError(w, requestErrors, requestLogger); err != nil {
218+
if err := writeMultipartError(w, requestErrors, requestContext.operation.opType); err != nil {
217219
if requestLogger != nil {
218220
requestLogger.Error("error writing multipart response", zap.Error(err))
219221
}
@@ -240,7 +242,11 @@ func writeRequestErrors(r *http.Request, w http.ResponseWriter, statusCode int,
240242
}
241243

242244
// writeMultipartError writes the error response in a multipart format with proper boundaries and headers.
243-
func writeMultipartError(w http.ResponseWriter, requestErrors graphqlerrors.RequestErrors, requestLogger *zap.Logger) error {
245+
func writeMultipartError(
246+
w http.ResponseWriter,
247+
requestErrors graphqlerrors.RequestErrors,
248+
operationType OperationType,
249+
) error {
244250
// Start with the multipart boundary
245251
prefix := GetWriterPrefix(false, true, true)
246252
if _, err := w.Write([]byte(prefix)); err != nil {
@@ -257,12 +263,21 @@ func writeMultipartError(w http.ResponseWriter, requestErrors graphqlerrors.Requ
257263
return err
258264
}
259265

260-
resp, err := wrapMultipartMessage(responseBytes)
266+
isSubscription := operationType == "subscription"
267+
resp, err := wrapMultipartMessage(responseBytes, isSubscription)
261268
if err != nil {
262269
return err
263270
}
264271

265-
resp = append(resp, '\n')
272+
// The multipart spec requires us to use both CRLF (\r and \n) characters together. Since we didn't do this
273+
// before, some clients that rely on both CR and LF strictly to parse blocks were broken and not parsing our
274+
// multipart chunks correctly. With this fix here (and in a few other places) the clients are now working.
275+
if isSubscription {
276+
resp = append(resp, '\r', '\n')
277+
} else {
278+
resp = append(resp, []byte("\r\n--graphql--")...)
279+
}
280+
266281
if _, err := w.Write([]byte(resp)); err != nil {
267282
return err
268283
}

router/core/flushwriter.go

+12-8
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (f *HttpFlushWriter) Flush() (err error) {
8888
}
8989
if f.multipart && len(resp) > 0 {
9090
var err error
91-
resp, err = wrapMultipartMessage(resp)
91+
resp, err = wrapMultipartMessage(resp, true)
9292
if err != nil {
9393
return err
9494
}
@@ -156,23 +156,27 @@ func GetSubscriptionResponseWriter(ctx *resolve.Context, r *http.Request, w http
156156
return ctx, flushWriter, true
157157
}
158158

159-
func wrapMultipartMessage(resp []byte) ([]byte, error) {
159+
func wrapMultipartMessage(resp []byte, wrapPayload bool) ([]byte, error) {
160160
if string(resp) == heartbeat {
161161
return resp, nil
162162
}
163163

164-
// Per the Apollo docs, multipart messages are supposed to be json, wrapped in ` "payload"`
165-
a, err := astjson.Parse(`{"payload": {}}`)
164+
respValuePreMerge, err := astjson.ParseBytes(resp)
166165
if err != nil {
167166
return nil, err
168167
}
169168

170-
b, err := astjson.ParseBytes(resp)
169+
if !wrapPayload {
170+
return respValuePreMerge.MarshalTo(nil), nil
171+
}
172+
173+
// Per the Apollo docs, multipart messages are supposed to be json, wrapped in `"payload"`
174+
// for subscriptions
175+
payloadWrapper, err := astjson.Parse(`{"payload": {}}`)
171176
if err != nil {
172177
return nil, err
173178
}
174-
175-
respValue, _, err := astjson.MergeValuesWithPath(a, b, "payload")
179+
respValue, _, err := astjson.MergeValuesWithPath(payloadWrapper, respValuePreMerge, "payload")
176180
if err != nil {
177181
return nil, err
178182
}
@@ -259,7 +263,7 @@ func GetWriterPrefix(sse bool, multipart bool, firstMessage bool) string {
259263
if firstMessage {
260264
messageStart = multipartStart
261265
}
262-
flushBreak = messageStart + "\nContent-Type: " + jsonContent + "\r\n\r\n"
266+
flushBreak = messageStart + "\r\nContent-Type: " + jsonContent + "\r\n\r\n"
263267
}
264268

265269
return flushBreak

router/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "router",
3-
"version": "0.189.0",
3+
"version": "0.189.1",
44
"private": true,
55
"description": "Placeholder package to simplify versioning and releasing with lerna.",
66
"keywords": [

0 commit comments

Comments
 (0)