Skip to content

Commit a6c19df

Browse files
alcomsfstef
andauthored
Recognize more common connection errors (#2858)
After doing a pass on Sentry issues I've added a bunch more patterns to db_connection_error.ex. As we see more errors recurring in Sentry, we should add them to patterns in DbConnectionError so as not to treat them as unknown anymore. --------- Co-authored-by: msfstef <msfstef@gmail.com>
1 parent 0698666 commit a6c19df

5 files changed

Lines changed: 149 additions & 46 deletions

File tree

.changeset/many-rocks-provide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@core/sync-service": patch
3+
---
4+
5+
Recognize additional common connection errors and handle appropriately.

packages/sync-service/lib/electric/connection/manager.ex

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -600,13 +600,13 @@ defmodule Electric.Connection.Manager do
600600
# Try repairing the connection opts and try connecting again. If we're already using noSSL
601601
# and IPv4, the error will be propagated to a `shutdown_or_reconnect()` function call
602602
# further down below.
603-
error = strip_exit_signal_stacktrace(reason)
604603
state = nillify_pid(state, pid)
604+
605605
{step, _} = state.current_step
606606
conn_opts = connection_opts_for_step(step, state)
607607

608608
repaired_conn_opts =
609-
case error do
609+
case reason do
610610
%Postgrex.Error{message: "ssl not available"} ->
611611
maybe_fallback_to_no_ssl(conn_opts)
612612

@@ -625,7 +625,7 @@ defmodule Electric.Connection.Manager do
625625
state = update_connection_opts(step, repaired_conn_opts, state)
626626
{:noreply, state, {:continue, step}}
627627
else
628-
shutdown_or_reconnect(error, state)
628+
shutdown_or_reconnect(reason, state)
629629
end
630630
end
631631

@@ -638,15 +638,14 @@ defmodule Electric.Connection.Manager do
638638
%State{replication_client_pid: pid, current_phase: :running} = state
639639
) do
640640
state = nillify_pid(state, pid)
641-
error = strip_exit_signal_stacktrace(reason)
642641

643642
state = %{
644643
state
645644
| current_phase: :restarting_replication_client,
646645
current_step: {:start_replication_client, nil}
647646
}
648647

649-
shutdown_or_reconnect(error, state)
648+
shutdown_or_reconnect(reason, state)
650649
end
651650

652651
# The most likely reason for any database connection to get closed after we've already opened a
@@ -972,7 +971,10 @@ defmodule Electric.Connection.Manager do
972971
end
973972

974973
defp shutdown_or_reconnect(error, state) do
975-
error = DbConnectionError.from_error(error)
974+
error =
975+
error
976+
|> strip_exit_signal_stacktrace()
977+
|> DbConnectionError.from_error()
976978

977979
with false <- drop_slot_and_restart(error, state),
978980
false <- stop_if_fatal_error(error, state) do
@@ -1287,13 +1289,29 @@ defmodule Electric.Connection.Manager do
12871289
:erlang.start_timer(@connection_status_check_interval, self(), {:check_status, type})
12881290
end
12891291

1290-
# It's possible that the exit signal received from the replication client process includes a
1291-
# stacktrace. I haven't found the rule that would describe when the stacktrace is to be
1292-
# expected or not. This implementation is based on empirical evidence.
1293-
defp strip_exit_signal_stacktrace(signal) do
1294-
case signal do
1295-
{reason, stacktrace} when is_list(stacktrace) -> reason
1296-
reason -> reason
1292+
# It's possible that the exit signal received from an exiting process includes a
1293+
# stacktrace. This happens when the process crashes due an exception getting raised as
1294+
# opposed to exiting with a custom reason in an orderly fashion.
1295+
defp strip_exit_signal_stacktrace(error) do
1296+
case error do
1297+
{reason, stacktrace} when is_list(stacktrace) ->
1298+
if stacktrace?(stacktrace) do
1299+
reason
1300+
else
1301+
error
1302+
end
1303+
1304+
_ ->
1305+
error
1306+
end
1307+
end
1308+
1309+
defp stacktrace?(val) do
1310+
try do
1311+
_ = Exception.format_stacktrace(val)
1312+
true
1313+
rescue
1314+
_ -> false
12971315
end
12981316
end
12991317

packages/sync-service/lib/electric/db_connection_error.ex

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,35 @@ defmodule Electric.DbConnectionError do
55

66
alias Electric.DbConnectionError
77

8-
def from_error(
9-
%DBConnection.ConnectionError{message: "tcp recv: connection timed out - :etimedout"} =
10-
error
11-
) do
8+
def from_error(%DBConnection.ConnectionError{message: message} = error)
9+
when message in [
10+
"tcp recv (idle): closed",
11+
"ssl recv (idle): closed",
12+
"tcp recv: closed",
13+
"ssl recv: closed",
14+
"tcp async recv: closed",
15+
"ssl async recv: closed",
16+
"tcp async_recv: closed",
17+
"ssl async_recv: closed"
18+
] do
19+
%DbConnectionError{
20+
message: "connection closed while connecting to the database",
21+
type: :connection_closed,
22+
original_error: error,
23+
retry_may_fix?: true
24+
}
25+
end
26+
27+
def from_error(%DBConnection.ConnectionError{message: message} = error)
28+
when message in [
29+
"tcp recv: connection timed out - :etimedout",
30+
"tcp recv (idle): timeout",
31+
"ssl recv (idle): timeout",
32+
"tcp recv: timeout",
33+
"ssl recv: timeout"
34+
] do
1235
%DbConnectionError{
13-
message: "connection timed out while trying to connect to the database",
36+
message: "connection timed out",
1437
type: :connection_timeout,
1538
original_error: error,
1639
retry_may_fix?: true
@@ -19,13 +42,11 @@ defmodule Electric.DbConnectionError do
1942

2043
def from_error(%DBConnection.ConnectionError{message: message} = error)
2144
when message in [
22-
"tcp recv (idle): closed",
23-
"ssl recv (idle): closed",
24-
"tcp recv: closed",
25-
"ssl recv: closed"
45+
"tcp send: closed",
46+
"ssl send: closed"
2647
] do
2748
%DbConnectionError{
28-
message: "connection closed while connecting to the database",
49+
message: "connection closed while talking to the database",
2950
type: :connection_closed,
3051
original_error: error,
3152
retry_may_fix?: true
@@ -152,7 +173,8 @@ defmodule Electric.DbConnectionError do
152173
end
153174

154175
def from_error(%Postgrex.Error{postgres: %{code: :internal_error, pg_code: "XX000"}} = error) do
155-
maybe_database_does_not_exist(error) || unknown_error(error)
176+
maybe_database_does_not_exist(error) || maybe_compute_quota_exceeded(error) ||
177+
unknown_error(error)
156178
end
157179

158180
def from_error(
@@ -212,6 +234,21 @@ defmodule Electric.DbConnectionError do
212234
end
213235
end
214236

237+
defp maybe_compute_quota_exceeded(error) do
238+
case error.postgres.message do
239+
"Your account or project has exceeded the compute time quota" <> _ ->
240+
%DbConnectionError{
241+
message: error.postgres.message,
242+
type: :compute_quota_exceeded,
243+
original_error: error,
244+
retry_may_fix?: false
245+
}
246+
247+
_ ->
248+
nil
249+
end
250+
end
251+
215252
defp maybe_nxdomain_error(error) do
216253
~r/\((?<domain>[^:]+).*\): non-existing domain - :nxdomain/
217254
|> Regex.named_captures(error.message)

packages/sync-service/lib/electric/postgres/replication_client.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,11 @@ defmodule Electric.Postgres.ReplicationClient do
221221
{:disconnect, :shutdown}
222222
end
223223

224+
# Some other exit reason we're not expecting: disconnect and shut down.
225+
def handle_info({:EXIT, _pid, reason}, _state) do
226+
{:disconnect, reason}
227+
end
228+
224229
# The implementation of Postgrex.ReplicationConnection doesn't give us a convenient way to
225230
# check whether the START_REPLICATION_SLOT statement succeeded before switching the
226231
# connection into streaming mode. Returning {:query, "START_REPLICATION_SLOT ...", state}

packages/sync-service/test/electric/db_connection_error_test.exs

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -71,33 +71,47 @@ defmodule Electric.DbConnectionErrorTest do
7171
end
7272

7373
test "with a tcp timeout error" do
74-
error = %DBConnection.ConnectionError{
75-
message: "tcp recv: connection timed out - :etimedout",
76-
severity: :error,
77-
reason: :error
78-
}
74+
for message <- [
75+
"tcp recv: connection timed out - :etimedout",
76+
"ssl recv (idle): timeout",
77+
"tcp recv: timeout"
78+
] do
79+
error = %DBConnection.ConnectionError{
80+
message: message,
81+
severity: :error,
82+
reason: :error
83+
}
7984

80-
assert %DbConnectionError{
81-
message: "connection timed out while trying to connect to the database",
82-
type: :connection_timeout,
83-
original_error: error,
84-
retry_may_fix?: true
85-
} == DbConnectionError.from_error(error)
85+
assert %DbConnectionError{
86+
message: "connection timed out",
87+
type: :connection_timeout,
88+
original_error: error,
89+
retry_may_fix?: true
90+
} == DbConnectionError.from_error(error)
91+
end
8692
end
8793

8894
test "with tcp closed error" do
89-
error = %DBConnection.ConnectionError{
90-
message: "tcp recv (idle): closed",
91-
severity: :error,
92-
reason: :error
93-
}
95+
for message <- [
96+
"tcp recv (idle): closed",
97+
"ssl recv (idle): closed",
98+
"tcp recv: closed",
99+
"ssl recv: closed",
100+
"ssl async_recv: closed"
101+
] do
102+
error = %DBConnection.ConnectionError{
103+
message: message,
104+
severity: :error,
105+
reason: :error
106+
}
94107

95-
assert %DbConnectionError{
96-
message: "connection closed while connecting to the database",
97-
type: :connection_closed,
98-
original_error: error,
99-
retry_may_fix?: true
100-
} == DbConnectionError.from_error(error)
108+
assert %DbConnectionError{
109+
message: "connection closed while connecting to the database",
110+
type: :connection_closed,
111+
original_error: error,
112+
retry_may_fix?: true
113+
} == DbConnectionError.from_error(error)
114+
end
101115
end
102116

103117
test "with a connection refused error" do
@@ -114,5 +128,29 @@ defmodule Electric.DbConnectionErrorTest do
114128
retry_may_fix?: false
115129
} == DbConnectionError.from_error(error)
116130
end
131+
132+
test "with compute quota exceeded error" do
133+
error = %Postgrex.Error{
134+
message: nil,
135+
postgres: %{
136+
code: :internal_error,
137+
message:
138+
"Your account or project has exceeded the compute time quota. Upgrade your plan to increase limits.",
139+
unknown: "FATAL",
140+
severity: "FATAL",
141+
pg_code: "XX000"
142+
},
143+
connection_id: nil,
144+
query: nil
145+
}
146+
147+
assert %DbConnectionError{
148+
message:
149+
"Your account or project has exceeded the compute time quota. Upgrade your plan to increase limits.",
150+
type: :compute_quota_exceeded,
151+
original_error: error,
152+
retry_may_fix?: false
153+
} == DbConnectionError.from_error(error)
154+
end
117155
end
118156
end

0 commit comments

Comments
 (0)