Skip to content

Commit f810e82

Browse files
authored
fix: Return 409 must-refetch instead of 400 on shape definition and handle mismatch (#2476)
Fixes #2474 Currently, Electric returns a 400 error code when the shape definition and shape handle do not match. However, we discovered a pattern for handling dependent shapes on the proxy where shape B is constructed based on the results for shape A. For example, if we want to load all teams for a user we would first create a shape to get all team_ids a user belongs to and then another shape that selects all teams where `team_id IN (team_id_1, ..., team_id_n)`. The definition of the second shape changes whenever the results of the first shape change. For this to work properly, we need Electric to return 409 must-refetch with the handle of the new shape when the shape definition changes.
1 parent 844a54f commit f810e82

File tree

6 files changed

+91
-93
lines changed

6 files changed

+91
-93
lines changed

.changeset/shy-eagles-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@core/sync-service": minor
3+
---
4+
5+
Electric now also returns 409 instead of 400 when the shape handle and the shape definition do not match.

packages/sync-service/lib/electric/shapes/api.ex

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -259,23 +259,14 @@ defmodule Electric.Shapes.Api do
259259
end
260260

261261
defp handle_shape_info(nil, %Request{} = request) do
262-
%{params: %{shape_definition: shape, handle: shape_handle}, api: api} = request
263-
# There is no shape that matches the shape definition (because shape info is `nil`)
264-
if shape_handle != nil && Shapes.has_shape?(api, shape_handle) do
265-
# but there is a shape that matches the shape handle
266-
# thus the shape handle does not match the shape definition
267-
# and we return a 400 bad request status code
268-
{:error, Response.shape_definition_mismatch(request)}
269-
else
270-
# The shape handle does not exist or no longer exists
271-
# e.g. it may have been deleted.
272-
# Hence, create a new shape for this shape definition
273-
# and return a 409 with a redirect to the newly created shape.
274-
# (will be done by the recursive `handle_shape_info` call)
275-
api
276-
|> Shapes.get_or_create_shape_handle(shape)
277-
|> handle_shape_info(request)
278-
end
262+
%{params: %{shape_definition: shape}, api: api} = request
263+
# There is no shape that matches the shape definition (because shape info is `nil`).
264+
# Hence, create a new shape for this shape definition
265+
# and return a 409 with a redirect to the newly created shape.
266+
# (will be done by the recursive `handle_shape_info` call)
267+
api
268+
|> Shapes.get_or_create_shape_handle(shape)
269+
|> handle_shape_info(request)
279270
end
280271

281272
defp handle_shape_info(
@@ -303,26 +294,28 @@ defmodule Electric.Shapes.Api do
303294

304295
defp handle_shape_info(
305296
{active_shape_handle, _},
306-
%Request{params: %{handle: shape_handle}} = request
297+
%Request{} = request
307298
) do
308-
if Shapes.has_shape?(request.api, shape_handle) do
309-
# The shape with the provided ID exists but does not match the shape definition
310-
# otherwise we would have found it and it would have matched the previous function clause
311-
{:error, Response.shape_definition_mismatch(request)}
312-
else
313-
# The requested shape_handle is not found, returns 409 along with a location redirect for clients to
314-
# re-request the shape from scratch with the new shape id which acts as a consistent cache buster
315-
# e.g. GET /v1/shape?table={root_table}&handle={new_shape_handle}&offset=-1
316-
317-
# TODO: discuss returning a 307 redirect rather than a 409, the client
318-
# will have to detect this and throw out old data
319-
320-
{:error,
321-
Response.error(request, @must_refetch,
322-
handle: active_shape_handle,
323-
status: 409
324-
)}
325-
end
299+
# Either the requested shape handle exists or does not exist.
300+
# If it exists there is a mismatch between the shape definition and the shape handle
301+
# (otherwise we would have matched the previous function clause).
302+
# The mismatch may occur because the shape definition has changed,
303+
# which happens frequently when working with dependent shapes
304+
# where a shape's WHERE clause is constructed based on the values of another shape
305+
# (e.g. to load all children pointed at by a FK in a parent table).
306+
307+
# If the shape handle does not exist, it may have never existed or it may have been deleted.
308+
# In either case we return a 409 with a location redirect for clients to
309+
# re-request the shape from scratch with the new shape id which acts as a consistent cache buster
310+
# e.g. GET /v1/shape?table={root_table}&handle={new_shape_handle}&offset=-1
311+
312+
# TODO: discuss returning a 307 redirect rather than a 409, the client
313+
# will have to detect this and throw out old data
314+
{:error,
315+
Response.error(request, @must_refetch,
316+
handle: active_shape_handle,
317+
status: 409
318+
)}
326319
end
327320

328321
defp hold_until_stack_ready(%Api{} = api) do

packages/sync-service/test/electric/plug/router_test.exs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,7 +1199,7 @@ defmodule Electric.Plug.RouterTest do
11991199
] = Jason.decode!(conn.resp_body)
12001200
end
12011201

1202-
test "GET receives 400 when shape handle does not match shape definition", %{
1202+
test "GET receives 409 when shape handle does not match shape definition", %{
12031203
opts: opts
12041204
} do
12051205
where = "value ILIKE 'yes%'"
@@ -1221,13 +1221,17 @@ defmodule Electric.Plug.RouterTest do
12211221
conn("GET", "/v1/shape?table=items", %{offset: next_offset, handle: shape_handle})
12221222
|> Router.call(opts)
12231223

1224-
assert %{status: 400} = conn
1224+
assert %{status: 409} = conn
12251225

1226-
assert conn.resp_body ==
1227-
Jason.encode!(%{
1228-
message:
1229-
"The specified shape definition and handle do not match. Please ensure the shape definition is correct or omit the shape handle from the request to obtain a new one."
1230-
})
1226+
assert Jason.decode!(conn.resp_body) == [
1227+
%{"headers" => %{"control" => "must-refetch"}}
1228+
]
1229+
1230+
new_shape_handle = get_resp_header(conn, "electric-handle")
1231+
assert new_shape_handle != shape_handle
1232+
1233+
assert get_resp_header(conn, "location") ==
1234+
"/v1/shape?handle=#{new_shape_handle}&offset=-1&table=items"
12311235
end
12321236

12331237
test "GET receives 409 to a newly created shape when shape handle is not found and no shape matches the shape definition",

packages/sync-service/test/electric/plug/serve_shape_plug_test.exs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -677,15 +677,14 @@ defmodule Electric.Plug.ServeShapePlugTest do
677677
]
678678
end
679679

680-
test "sends 400 when shape handle does not match shape definition",
680+
test "sends 409 when shape handle does not match shape definition",
681681
ctx do
682+
new_shape_handle = "new-shape-handle"
683+
682684
Mock.ShapeCache
683685
|> expect(:get_shape, fn @test_shape, _opts -> nil end)
684-
|> stub(:has_shape?, fn @test_shape_handle, _opts -> true end)
685-
686-
Mock.Storage
687-
|> stub(:for_shape, fn @test_shape_handle, opts ->
688-
{@test_shape_handle, opts}
686+
|> expect(:get_or_create_shape_handle, fn @test_shape, _opts ->
687+
{new_shape_handle, @test_offset}
689688
end)
690689

691690
conn =
@@ -697,13 +696,14 @@ defmodule Electric.Plug.ServeShapePlugTest do
697696
)
698697
|> call_serve_shape_plug(ctx)
699698

700-
assert conn.status == 400
699+
assert conn.status == 409
701700

702-
assert Jason.decode!(conn.resp_body) == %{
703-
"message" =>
704-
"The specified shape definition and handle do not match." <>
705-
" Please ensure the shape definition is correct or omit the shape handle from the request to obtain a new one."
706-
}
701+
assert Jason.decode!(conn.resp_body) == [%{"headers" => %{"control" => "must-refetch"}}]
702+
assert get_resp_header(conn, "electric-handle") == [new_shape_handle]
703+
704+
assert get_resp_header(conn, "location") == [
705+
"/?handle=#{new_shape_handle}&offset=-1&table=public.users"
706+
]
707707
end
708708

709709
test "sends 400 when omitting primary key columns in selection", ctx do

packages/sync-service/test/electric/shapes/api_test.exs

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,11 @@ defmodule Electric.Shapes.ApiTest do
209209
|> expect(:get_shape, fn @test_shape, _opts ->
210210
nil
211211
end)
212-
|> expect(:has_shape?, fn ^request_handle, _opts ->
213-
true
212+
|> expect(:get_or_create_shape_handle, fn @test_shape, _opts ->
213+
{@test_shape_handle, @test_offset}
214214
end)
215215

216-
assert {:error, %{status: 400} = response} =
216+
assert {:error, %{status: 409} = response} =
217217
Api.validate(
218218
ctx.api,
219219
%{
@@ -223,9 +223,8 @@ defmodule Electric.Shapes.ApiTest do
223223
}
224224
)
225225

226-
assert %{
227-
message: "The specified shape definition and handle do not match" <> _
228-
} = response_body(response)
226+
assert response.handle == @test_shape_handle
227+
assert [%{headers: %{control: "must-refetch"}}] = response_body(response)
229228
end
230229

231230
test "shape for handle does not match the shape definition", ctx do
@@ -235,11 +234,8 @@ defmodule Electric.Shapes.ApiTest do
235234
|> expect(:get_shape, fn @test_shape, _opts ->
236235
{@test_shape_handle, @before_all_offset}
237236
end)
238-
|> expect(:has_shape?, fn ^request_handle, _opts ->
239-
true
240-
end)
241237

242-
assert {:error, %{status: 400} = response} =
238+
assert {:error, %{status: 409} = response} =
243239
Api.validate(
244240
ctx.api,
245241
%{
@@ -249,9 +245,8 @@ defmodule Electric.Shapes.ApiTest do
249245
}
250246
)
251247

252-
assert %{
253-
message: "The specified shape definition and handle do not match" <> _
254-
} = response_body(response)
248+
assert response.handle == @test_shape_handle
249+
assert [%{headers: %{control: "must-refetch"}}] = response_body(response)
255250
end
256251

257252
test "returns a 409 error when requested shape handle does not exist", ctx do
@@ -261,9 +256,6 @@ defmodule Electric.Shapes.ApiTest do
261256
|> expect(:get_shape, fn @test_shape, _opts ->
262257
{@test_shape_handle, @before_all_offset}
263258
end)
264-
|> expect(:has_shape?, fn ^request_handle, _opts ->
265-
false
266-
end)
267259

268260
assert {:error, %{status: 409} = response} =
269261
Api.validate(

packages/typescript-client/test/integration.test.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -635,8 +635,8 @@ describe(`HTTP Sync`, () => {
635635
const shapeOffset = res.headers.get(`electric-offset`)!
636636
const fakeMidOffset = shapeOffset
637637
.split(`_`)
638-
.map(BigInt)
639-
.map((x, i) => (i === 0 ? x - BigInt(1) : x))
638+
.map(Number)
639+
.map((x, i) => (i === 0 ? x - 1 : x))
640640
.join(`_`)
641641
const etag = res.headers.get(`etag`)
642642
expect(etag, `Response should have etag header`).not.toBe(null)
@@ -818,9 +818,8 @@ describe(`HTTP Sync`, () => {
818818
const responseSize = responseSizes.shift()
819819
const isLastResponse = responseSizes.length === 0
820820
if (!isLastResponse) {
821-
// expect chunks to be close to 10 kB +- some kB
822-
const expectedSize = 10 * 1e3
823-
expect(responseSize).closeTo(expectedSize, expectedSize * 0.2)
821+
// expect chunks to be close to 10 kB +- 20%
822+
expect(responseSize).closeTo(10 * 1e3, 2e3)
824823
} else {
825824
// expect last response to be ~ 10 kB or less
826825
expect(responseSize).toBeLessThan(11 * 1e3)
@@ -842,7 +841,7 @@ describe(`HTTP Sync`, () => {
842841
url: `${BASE_URL}/v1/shape`,
843842
params: {
844843
table: issuesTableUrl,
845-
where: `1=1`,
844+
where: `1 x 1`, // invalid SQL
846845
},
847846
signal: aborter.signal,
848847
handle: streamState.handle,
@@ -859,16 +858,18 @@ describe(`HTTP Sync`, () => {
859858
expect(invalidIssueStream.error).instanceOf(FetchError)
860859
expect((invalidIssueStream.error! as FetchError).status).toBe(400)
861860
expect(invalidIssueStream.isConnected()).false
862-
expect(error!.message).contains(
863-
`The specified shape definition and handle do not match. Please ensure the shape definition is correct or omit the shape handle from the request to obtain a new one.`
864-
)
861+
expect((error! as FetchError).json).toStrictEqual({
862+
message: `Invalid request`,
863+
errors: {
864+
where: [`At location 17: syntax error at or near "x"`],
865+
},
866+
})
865867
})
866868

867869
it(`should detect shape deprecation and restart syncing`, async ({
868870
expect,
869871
insertIssues,
870872
issuesTableUrl,
871-
waitForIssues,
872873
aborter,
873874
clearIssuesShape,
874875
}) => {
@@ -878,12 +879,23 @@ describe(`HTTP Sync`, () => {
878879
await insertIssues({ id: rowId, title: `foo1` })
879880

880881
const statusCodesReceived: number[] = []
882+
let numRequests = 0
881883

882-
let fetchPausePromise = Promise.resolve()
883884
const fetchWrapper = async (...args: Parameters<typeof fetch>) => {
884-
await fetchPausePromise
885+
// before any subsequent requests after the initial one, ensure
886+
// that the existing shape is deleted and some more data is inserted
887+
if (numRequests === 2) {
888+
await insertIssues({ id: rowId2, title: `foo2` })
889+
await clearIssuesShape(issueStream.shapeHandle)
890+
}
891+
892+
numRequests++
885893
const response = await fetch(...args)
886-
if (response.status < 500) statusCodesReceived.push(response.status)
894+
895+
if (response.status < 500) {
896+
statusCodesReceived.push(response.status)
897+
}
898+
887899
return response
888900
}
889901

@@ -911,19 +923,11 @@ describe(`HTTP Sync`, () => {
911923
expect(statusCodesReceived).toHaveLength(2)
912924
expect(statusCodesReceived[0]).toBe(200)
913925
expect(statusCodesReceived[1]).toBe(200)
914-
915-
// before any subsequent requests after the initial one, ensure
916-
// that the existing shape is deleted and some more data is inserted
917-
fetchPausePromise = Promise.resolve().then(async () => {
918-
await insertIssues({ id: rowId2, title: `foo2` })
919-
await waitForIssues({ numChangesExpected: 2 })
920-
await clearIssuesShape(issueStream.shapeHandle)
921-
})
922926
} else if (upToDateReachedCount === 2) {
923927
// the next up to date message should have had
924928
// a 409 interleaved before it that instructed the
925929
// client to go and fetch data from scratch
926-
expect(statusCodesReceived.length).toBeGreaterThanOrEqual(5)
930+
expect(statusCodesReceived).toHaveLength(5)
927931
expect(statusCodesReceived[2]).toBe(409)
928932
expect(statusCodesReceived[3]).toBe(200)
929933
return res()

0 commit comments

Comments
 (0)