Repository: favonia/cloudflare-ddns (go)

Issue:
Bail out and give better messages if timeout
Currently, when timeout happens, the tool will still make lots of API calls and users will see lots of useless "context timeout" messages. Instead, the tool should bail out and suggest changes to the configurations.

Pull request description:
feat(updater): bail out when it times out
Close #638

Patch:
diff --git a/internal/config/config_print.go b/internal/config/config_print.go
index f0805da..7a8012d 100644
--- a/internal/config/config_print.go
+++ b/internal/config/config_print.go
@@ -80,7 +80,7 @@ func (c *Config) Print(ppfmt pp.PP) {
 
 	section("Scheduling:")
 	item("Timezone:", "%s", cron.DescribeLocation(time.Local))
-	item("Update frequency:", "%s", cron.DescribeSchedule(c.UpdateCron))
+	item("Update schedule:", "%s", cron.DescribeSchedule(c.UpdateCron))
 	item("Update on start?", "%t", c.UpdateOnStart)
 	item("Delete on stop?", "%t", c.DeleteOnStop)
 	item("Cache expiration:", "%v", c.CacheExpiration)
diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go
index 4c8a7ef..01d8136 100644
--- a/internal/notifier/shoutrrr.go
+++ b/internal/notifier/shoutrrr.go
@@ -61,7 +61,7 @@ func (s *Shoutrrr) Send(_ context.Context, ppfmt pp.PP, msg string) bool {
 		}
 	}
 	if allOk {
-		ppfmt.Infof(pp.EmojiNotification, "Sent shoutrrr message")
+		ppfmt.Infof(pp.EmojiMessage, "Sent shoutrrr message")
 	}
 	return allOk
 }
diff --git a/internal/pp/emoji.go b/internal/pp/emoji.go
index 22564f7..022dc83 100644
--- a/internal/pp/emoji.go
+++ b/internal/pp/emoji.go
@@ -19,9 +19,10 @@ const (
 	EmojiDeleteRecord Emoji = "💀" // deleting DNS records
 	EmojiUpdateRecord Emoji = "📡" // updating DNS records
 	EmojiClearRecord  Emoji = "🧹" // clearing DNS records when exiting
+	EmojiBailingOut   Emoji = "💨" // bailing out
 
-	EmojiPing         Emoji = "🔔" // pinging and health checks
-	EmojiNotification Emoji = "📨" // notifications
+	EmojiPing    Emoji = "🔔" // pinging and health checks
+	EmojiMessage Emoji = "📨" // notifications
 
 	EmojiSignal      Emoji = "🚨" // catching signals
 	EmojiAlreadyDone Emoji = "🤷" // DNS records were already up to date
diff --git a/internal/setter/setter.go b/internal/setter/setter.go
index 35ea1ab..82fa1e5 100644
--- a/internal/setter/setter.go
+++ b/internal/setter/setter.go
@@ -104,33 +104,37 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 		// Let's go through all stale records
 		for i, id := range unprocessedUnmatched {
 			// Let's try to update it first.
-			if ok := s.Handle.UpdateRecord(ctx, ppfmt, domain, ipnet, id, ip); !ok {
-				// If the updating fails, we will delete it.
-				if ok := s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id); ok {
-					ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)",
-						recordType, domainDescription, id)
+			if s.Handle.UpdateRecord(ctx, ppfmt, domain, ipnet, id, ip) {
+				// If the updating succeeds, we can move on to the next stage!
+				//
+				// Note that there can still be stale records at this point.
+				ppfmt.Noticef(pp.EmojiUpdateRecord,
+					"Updated a stale %s record of %q (ID: %q)", recordType, domainDescription, id)
+
+				// Now it's up to date! Note that unprocessedMatched must be empty
+				// otherwise foundMatched would have been true.
+				foundMatched = true
+				numUndeletedUnmatched--
+				newUnprocessedUnmatched = unprocessedUnmatched[i+1:]
+
+				break
+			}
+			if ctx.Err() != nil {
+				goto timeout
+			}
 
-					// Only when the deletion succeeds, we decrease the counter of remaining stale records.
-					numUndeletedUnmatched--
-				}
+			// If the updating fails, we will delete it.
+			if s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) {
+				ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)",
+					recordType, domainDescription, id)
 
-				// No matter whether the deletion succeeds, move on.
+				// Only when the deletion succeeds, we decrease the counter of remaining stale records.
+				numUndeletedUnmatched--
 				continue
 			}
-
-			// If the updating succeeds, we can move on to the next stage!
-			//
-			// Note that there can still be stale records at this point.
-			ppfmt.Noticef(pp.EmojiUpdateRecord,
-				"Updated a stale %s record of %q (ID: %q)", recordType, domainDescription, id)
-
-			// Now it's up to date! Note that unprocessedMatched must be empty
-			// otherwise foundMatched would have been true.
-			foundMatched = true
-			numUndeletedUnmatched--
-			newUnprocessedUnmatched = unprocessedUnmatched[i+1:]
-
-			break
+			if ctx.Err() != nil {
+				goto timeout
+			}
 		}
 
 		unprocessedUnmatched = newUnprocessedUnmatched
@@ -145,6 +149,8 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 
 			// Now it's up to date! unprocessedMatched and unprocessedUnmatched must both be empty at this point
 			foundMatched = true
+		} else if ctx.Err() != nil {
+			goto timeout
 		}
 	}
 
@@ -153,6 +159,8 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 		if s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) {
 			ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", recordType, domainDescription, id)
 			numUndeletedUnmatched--
+		} else if ctx.Err() != nil {
+			goto timeout
 		}
 	}
 
@@ -162,6 +170,8 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 		if s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) {
 			ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a duplicate %s record of %q (ID: %q)",
 				recordType, domainDescription, id)
+		} else if ctx.Err() != nil {
+			goto timeout
 		}
 	}
 
@@ -174,6 +184,10 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 	}
 
 	return ResponseUpdated
+
+timeout:
+	ppfmt.Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", ctx.Err())
+	return ResponseFailed
 }
 
 // Delete deletes all managed DNS records.
@@ -205,8 +219,13 @@ func (s *setter) Delete(ctx context.Context, ppfmt pp.PP, domain domain.Domain,
 
 	allOk := true
 	for _, id := range unmatchedIDs {
-		if ok := s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id); !ok {
+		if !s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) {
 			allOk = false
+
+			if ctx.Err() != nil {
+				ppfmt.Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", ctx.Err())
+				return ResponseFailed
+			}
 			continue
 		}
 
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 2313ab7..ad8475f 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -24,6 +24,7 @@ import (
 var ShouldDisplayHints = map[string]bool{
 	"detect-ip4-fail": true,
 	"detect-ip6-fail": true,
+	"detect-timeout":  true,
 	"update-timeout":  true,
 }
 
@@ -46,7 +47,7 @@ func getProxied(ppfmt pp.PP, c *config.Config, domain domain.Domain) bool {
 	return false
 }
 
-var errSettingTimeout = errors.New("setting timeout")
+var errTimeout = errors.New("timeout")
 
 // setIP extracts relevant settings from the configuration and calls [setter.Setter.Set] with timeout.
 // ip must be non-zero.
@@ -56,15 +57,16 @@ func setIP(ctx context.Context, ppfmt pp.PP,
 	resps := SetterResponses{}
 
 	for _, domain := range c.Domains[ipNet] {
-		ctx, cancel := context.WithTimeoutCause(ctx, c.UpdateTimeout, errSettingTimeout)
+		ctx, cancel := context.WithTimeoutCause(ctx, c.UpdateTimeout, errTimeout)
 		defer cancel()
 
 		resp := s.Set(ctx, ppfmt, domain, ipNet, ip, c.TTL, getProxied(ppfmt, c, domain), c.RecordComment)
 		resps.Register(resp, domain)
 		if resp == setter.ResponseFailed {
-			if ShouldDisplayHints["update-timeout"] && errors.Is(context.Cause(ctx), errSettingTimeout) {
+			if ShouldDisplayHints["update-timeout"] && errors.Is(context.Cause(ctx), errTimeout) {
 				ppfmt.Infof(pp.EmojiHint,
-					"If your network is experiencing high latency, consider increasing the value of UPDATE_TIMEOUT",
+					"If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v",
+					c.UpdateTimeout,
 				)
 				ShouldDisplayHints["update-timeout"] = false
 			}
@@ -81,11 +83,20 @@ func deleteIP(
 	resps := SetterResponses{}
 
 	for _, domain := range c.Domains[ipNet] {
-		ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout)
+		ctx, cancel := context.WithTimeoutCause(ctx, c.UpdateTimeout, errTimeout)
 		defer cancel()
 
 		resp := s.Delete(ctx, ppfmt, domain, ipNet)
 		resps.Register(resp, domain)
+		if resp == setter.ResponseFailed {
+			if ShouldDisplayHints["update-timeout"] && errors.Is(context.Cause(ctx), errTimeout) {
+				ppfmt.Infof(pp.EmojiHint,
+					"If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v",
+					c.UpdateTimeout,
+				)
+				ShouldDisplayHints["update-timeout"] = false
+			}
+		}
 	}
 
 	return GenerateDeleteMessage(ipNet, resps)
@@ -94,7 +105,7 @@ func deleteIP(
 func detectIP(ctx context.Context, ppfmt pp.PP,
 	c *config.Config, ipNet ipnet.Type, use1001 bool,
 ) (netip.Addr, message.Message) {
-	ctx, cancel := context.WithTimeout(ctx, c.DetectionTimeout)
+	ctx, cancel := context.WithTimeoutCause(ctx, c.DetectionTimeout, errTimeout)
 	defer cancel()
 
 	ip, ok := c.Provider[ipNet].GetIP(ctx, ppfmt, ipNet, use1001)
@@ -102,7 +113,14 @@ func detectIP(ctx context.Context, ppfmt pp.PP,
 		ppfmt.Infof(pp.EmojiInternet, "Detected the %s address: %v", ipNet.Describe(), ip)
 	} else {
 		ppfmt.Errorf(pp.EmojiError, "Failed to detect the %s address", ipNet.Describe())
-		if ShouldDisplayHints[getHintIDForDetection(ipNet)] {
+
+		if ShouldDisplayHints["detect-timeout"] && errors.Is(context.Cause(ctx), errTimeout) {
+			ppfmt.Infof(pp.EmojiHint,
+				"If your network is experiencing high latency, consider increasing DETECTION_TIMEOUT=%v",
+				c.DetectionTimeout,
+			)
+			ShouldDisplayHints["detect-timeout"] = false
+		} else if ShouldDisplayHints[getHintIDForDetection(ipNet)] {
 			switch ipNet {
 			case ipnet.IP6:
 				ppfmt.Infof(pp.EmojiHint, "If you are using Docker or Kubernetes, IPv6 often requires additional setups")     //nolint:lll


Test patch:
diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go
index c3cef99..4aac287 100644
--- a/internal/config/config_print_test.go
+++ b/internal/config/config_print_test.go
@@ -38,7 +38,7 @@ func TestPrintDefault(t *testing.T) {
 		printItem(innerMockPP, "IPv6 provider:", "cloudflare.trace"),
 		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
 		printItem(innerMockPP, "Timezone:", gomock.AnyOf("UTC (currently UTC+00)", "Local (currently UTC+00)")),
-		printItem(innerMockPP, "Update frequency:", "@every 5m"),
+		printItem(innerMockPP, "Update schedule:", "@every 5m"),
 		printItem(innerMockPP, "Update on start?", "true"),
 		printItem(innerMockPP, "Delete on stop?", "false"),
 		printItem(innerMockPP, "Cache expiration:", "6h0m0s"),
@@ -74,7 +74,7 @@ func TestPrintValues(t *testing.T) {
 		printItem(innerMockPP, "IPv6 provider:", "cloudflare.trace"),
 		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
 		printItem(innerMockPP, "Timezone:", gomock.AnyOf("UTC (currently UTC+00)", "Local (currently UTC+00)")),
-		printItem(innerMockPP, "Update frequency:", "@every 5m"),
+		printItem(innerMockPP, "Update schedule:", "@every 5m"),
 		printItem(innerMockPP, "Update on start?", "true"),
 		printItem(innerMockPP, "Delete on stop?", "false"),
 		printItem(innerMockPP, "Cache expiration:", "6h0m0s"),
@@ -140,7 +140,7 @@ func TestPrintEmpty(t *testing.T) {
 		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
 		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
 		printItem(innerMockPP, "Timezone:", gomock.AnyOf("UTC (currently UTC+00)", "Local (currently UTC+00)")),
-		printItem(innerMockPP, "Update frequency:", "@once"),
+		printItem(innerMockPP, "Update schedule:", "@once"),
 		printItem(innerMockPP, "Update on start?", "false"),
 		printItem(innerMockPP, "Delete on stop?", "false"),
 		printItem(innerMockPP, "Cache expiration:", "0s"),
diff --git a/internal/notifier/shoutrrr_test.go b/internal/notifier/shoutrrr_test.go
index 278e51f..c4b773e 100644
--- a/internal/notifier/shoutrrr_test.go
+++ b/internal/notifier/shoutrrr_test.go
@@ -45,7 +45,7 @@ func TestShoutrrrSend(t *testing.T) {
 			"hello",
 			true, true,
 			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiNotification, "Sent shoutrrr message")
+				m.EXPECT().Infof(pp.EmojiMessage, "Sent shoutrrr message")
 			},
 		},
 		"ill-formed url": {
diff --git a/internal/setter/setter_test.go b/internal/setter/setter_test.go
index e94bac3..b7d439f 100644
--- a/internal/setter/setter_test.go
+++ b/internal/setter/setter_test.go
@@ -16,6 +16,27 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 )
 
+func wrapCancelAsCreate(cancel func()) func(context.Context, pp.PP, domain.Domain, ipnet.Type, netip.Addr, api.TTL, bool, string) (string, bool) { //nolint:lll
+	return func(context.Context, pp.PP, domain.Domain, ipnet.Type, netip.Addr, api.TTL, bool, string) (string, bool) {
+		cancel()
+		return "", false
+	}
+}
+
+func wrapCancelAsUpdate(cancel func()) func(context.Context, pp.PP, domain.Domain, ipnet.Type, string, netip.Addr) bool { //nolint:lll
+	return func(context.Context, pp.PP, domain.Domain, ipnet.Type, string, netip.Addr) bool {
+		cancel()
+		return false
+	}
+}
+
+func wrapCancelAsDelete(cancel func()) func(context.Context, pp.PP, domain.Domain, ipnet.Type, string) bool {
+	return func(context.Context, pp.PP, domain.Domain, ipnet.Type, string) bool {
+		cancel()
+		return false
+	}
+}
+
 //nolint:funlen
 func TestSet(t *testing.T) {
 	t.Parallel()
@@ -36,7 +57,7 @@ func TestSet(t *testing.T) {
 		ip                netip.Addr
 		resp              setter.ResponseCode
 		prepareMockPP     func(m *mocks.MockPP)
-		prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle)
+		prepareMockHandle func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle)
 	}{
 		"0": {
 			ip1,
@@ -44,7 +65,7 @@ func TestSet(t *testing.T) {
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, true, true),
 					m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, api.TTLAuto, false, "hello").Return(record1, true),
@@ -62,14 +83,14 @@ func TestSet(t *testing.T) {
 					record1,
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2}, true, true),
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true),
 				)
 			},
 		},
-		"1unmatched-updatefail": {
+		"1unmatched/update-fail": {
 			ip1,
 			setter.ResponseUpdated,
 			func(m *mocks.MockPP) {
@@ -78,7 +99,7 @@ func TestSet(t *testing.T) {
 					m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2),
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2}, true, true),
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
@@ -87,6 +108,38 @@ func TestSet(t *testing.T) {
 				)
 			},
 		},
+		"1unmatched/update-timeout": {
+			ip1,
+			setter.ResponseFailed,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any())
+			},
+			func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip2}, true, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).
+						Do(wrapCancelAsUpdate(cancel)).Return(false),
+				)
+			},
+		},
+		"1unmatched/delete-timeout": {
+			ip1,
+			setter.ResponseFailed,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any())
+			},
+			func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip2}, true, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).
+						Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).
+						Do(wrapCancelAsDelete(cancel)).Return(false),
+				)
+			},
+		},
 		"1matched": {
 			ip1,
 			setter.ResponseNoop,
@@ -94,7 +147,7 @@ func TestSet(t *testing.T) {
 				m.EXPECT().Infof(pp.EmojiAlreadyDone,
 					"The %s records of %q are already up to date (cached)", "AAAA", "sub.test.org")
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true, true)
 			},
 		},
@@ -104,7 +157,7 @@ func TestSet(t *testing.T) {
 			func(m *mocks.MockPP) {
 				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", "AAAA", "sub.test.org")
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, false, true)
 			},
 		},
@@ -120,24 +173,41 @@ func TestSet(t *testing.T) {
 					record2,
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
-					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true), //nolint:lll
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
 				)
 			},
 		},
-		"2matched-deletefail": {
+		"2matched/delete-fail": {
 			ip1,
 			setter.ResponseUpdated,
 			nil,
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
-					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true), //nolint:lll
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(false),
 				)
 			},
 		},
+		"2matched/delete-timeout": {
+			ip1,
+			setter.ResponseFailed,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any())
+			},
+			func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).
+						Do(wrapCancelAsDelete(cancel)).Return(false),
+				)
+			},
+		},
 		"2unmatched": {
 			ip1,
 			setter.ResponseUpdated,
@@ -157,20 +227,51 @@ func TestSet(t *testing.T) {
 						record2),
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
-					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true),
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
 				)
 			},
 		},
-		"2unmatched-updatefail": {
+		"2unmatched/delete-timeout": {
+			ip1,
+			setter.ResponseFailed,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(
+						pp.EmojiUpdateRecord,
+						"Updated a stale %s record of %q (ID: %q)",
+						"AAAA",
+						"sub.test.org",
+						record1,
+					),
+					m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()),
+				)
+			},
+			func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).
+						Do(wrapCancelAsDelete(cancel)).Return(false),
+				)
+			},
+		},
+		"2unmatched/update-fail": {
 			ip1,
 			setter.ResponseUpdated,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
-					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1), //nolint:lll
+					m.EXPECT().Noticef(
+						pp.EmojiDeleteRecord,
+						"Deleted a stale %s record of %q (ID: %q)",
+						"AAAA",
+						"sub.test.org",
+						record1),
 					m.EXPECT().Noticef(
 						pp.EmojiUpdateRecord,
 						"Updated a stale %s record of %q (ID: %q)",
@@ -180,16 +281,17 @@ func TestSet(t *testing.T) {
 					),
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
-					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true),
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(true),
 				)
 			},
 		},
-		"2unmatched-updatefailtwice": {
+		"2unmatched/update-fail/update-fail": {
 			ip1,
 			setter.ResponseUpdated,
 			func(m *mocks.MockPP) {
@@ -199,9 +301,10 @@ func TestSet(t *testing.T) {
 					m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record3),
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
-					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true),
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(false),
@@ -211,7 +314,7 @@ func TestSet(t *testing.T) {
 			},
 		},
 		//nolint:dupl
-		"2unmatched-updatefail-deletefail-updatefail": {
+		"2unmatched/update-fail/delete-fail/update-fail": {
 			ip1,
 			setter.ResponseFailed,
 			func(m *mocks.MockPP) {
@@ -221,7 +324,7 @@ func TestSet(t *testing.T) {
 					m.EXPECT().Errorf(pp.EmojiError, "Failed to finish updating %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
@@ -233,7 +336,7 @@ func TestSet(t *testing.T) {
 			},
 		},
 		//nolint:dupl
-		"2unmatched-updatefailtwice-createfail": {
+		"2unmatched/update-fail/update-fail/create-fail": {
 			ip1,
 			setter.ResponseFailed,
 			func(m *mocks.MockPP) {
@@ -243,7 +346,7 @@ func TestSet(t *testing.T) {
 					m.EXPECT().Errorf(pp.EmojiError, "Failed to finish updating %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll
 					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
@@ -254,13 +357,36 @@ func TestSet(t *testing.T) {
 				)
 			},
 		},
+		"2unmatched/update-fail/update-fail/create-timeout": {
+			ip1,
+			setter.ResponseFailed,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1), //nolint:lll
+					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2), //nolint:lll
+					m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()),
+				)
+			},
+			func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
+					m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, api.TTLAuto, false, "hello").
+						Do(wrapCancelAsCreate(cancel)).Return(record3, false),
+				)
+			},
+		},
 		"listfail": {
 			ip1,
 			setter.ResponseFailed,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", "AAAA", "sub.test.org")
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(nil, false, false)
 			},
 		},
@@ -269,7 +395,8 @@ func TestSet(t *testing.T) {
 			t.Parallel()
 			mockCtrl := gomock.NewController(t)
 
-			ctx := context.Background()
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
 
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
@@ -277,7 +404,7 @@ func TestSet(t *testing.T) {
 			}
 			mockHandle := mocks.NewMockHandle(mockCtrl)
 			if tc.prepareMockHandle != nil {
-				tc.prepareMockHandle(ctx, mockPP, mockHandle)
+				tc.prepareMockHandle(ctx, cancel, mockPP, mockHandle)
 			}
 
 			s, ok := setter.New(mockPP, mockHandle)
@@ -308,14 +435,14 @@ func TestDelete(t *testing.T) {
 	for name, tc := range map[string]struct {
 		resp              setter.ResponseCode
 		prepareMockPP     func(m *mocks.MockPP)
-		prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle)
+		prepareMockHandle func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle)
 	}{
 		"0": {
 			setter.ResponseNoop,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q were already deleted (cached)", "AAAA", "sub.test.org")
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, true, true)
 			},
 		},
@@ -324,7 +451,7 @@ func TestDelete(t *testing.T) {
 			func(m *mocks.MockPP) {
 				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q were already deleted", "AAAA", "sub.test.org")
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, false, true)
 			},
 		},
@@ -333,25 +460,39 @@ func TestDelete(t *testing.T) {
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1) //nolint:lll
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true, true),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
 				)
 			},
 		},
-		"1unmatched/fail": {
+		"1unmatched/delete-fail": {
 			setter.ResponseFailed,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Errorf(pp.EmojiError, "Failed to finish deleting %s records of %q; records might be inconsistent", "AAAA", "sub.test.org") //nolint:lll
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true, true),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(false),
 				)
 			},
 		},
+		"1unmatched/delete-timeout": {
+			setter.ResponseFailed,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any())
+			},
+			func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).
+						Return(map[string]netip.Addr{record1: ip1}, true, true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).
+						Do(wrapCancelAsDelete(cancel)).Return(false),
+				)
+			},
+		},
 		"impossible-records": {
 			setter.ResponseUpdated,
 			func(m *mocks.MockPP) {
@@ -360,7 +501,7 @@ func TestDelete(t *testing.T) {
 					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2), //nolint:lll
 				)
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1, record2: invalidIP}, true, true), //nolint:lll
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
@@ -373,7 +514,7 @@ func TestDelete(t *testing.T) {
 			func(m *mocks.MockPP) {
 				m.EXPECT().Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", "AAAA", "sub.test.org")
 			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+			func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) {
 				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(nil, false, false)
 			},
 		},
@@ -382,7 +523,8 @@ func TestDelete(t *testing.T) {
 			t.Parallel()
 			mockCtrl := gomock.NewController(t)
 
-			ctx := context.Background()
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
 
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
@@ -390,7 +532,7 @@ func TestDelete(t *testing.T) {
 			}
 			mockHandle := mocks.NewMockHandle(mockCtrl)
 			if tc.prepareMockHandle != nil {
-				tc.prepareMockHandle(ctx, mockPP, mockHandle)
+				tc.prepareMockHandle(ctx, cancel, mockPP, mockHandle)
 			}
 
 			s, ok := setter.New(mockPP, mockHandle)
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index 9df2cd8..f680feb 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -22,7 +22,7 @@ import (
 
 const RecordComment string = "hello"
 
-//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable
 func TestUpdateIPsMultiple(t *testing.T) {
 	domain4_1 := domain.FQDN("ip4.hello1")
 	domain4_2 := domain.FQDN("ip4.hello2")
@@ -104,6 +104,7 @@ func TestUpdateIPsMultiple(t *testing.T) {
 			}
 			conf.RecordComment = RecordComment
 			conf.Use1001 = true
+			conf.DetectionTimeout = time.Second
 			conf.UpdateTimeout = time.Second
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
@@ -135,7 +136,7 @@ func TestUpdateIPsMultiple(t *testing.T) {
 	}
 }
 
-//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable
 func TestDeleteIPsMultiple(t *testing.T) {
 	domain4_1 := domain.FQDN("ip4.hello1")
 	domain4_2 := domain.FQDN("ip4.hello2")
@@ -203,6 +204,7 @@ func TestDeleteIPsMultiple(t *testing.T) {
 			}
 			conf.RecordComment = RecordComment
 			conf.Use1001 = true
+			conf.DetectionTimeout = time.Second
 			conf.UpdateTimeout = time.Second
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
@@ -228,8 +230,8 @@ func TestDeleteIPsMultiple(t *testing.T) {
 	}
 }
 
-//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
-func TestUpdateIPsUninitializedProbied(t *testing.T) {
+//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable
+func TestUpdateIPsUninitializedProxied(t *testing.T) {
 	domain4 := domain.FQDN("ip4.hello")
 	domains := map[ipnet.Type][]domain.Domain{
 		ipnet.IP4: {domain4},
@@ -279,6 +281,7 @@ func TestUpdateIPsUninitializedProbied(t *testing.T) {
 			conf.Proxied = map[domain.Domain]bool{}
 			conf.RecordComment = RecordComment
 			conf.Use1001 = true
+			conf.DetectionTimeout = time.Second
 			conf.UpdateTimeout = time.Second
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
@@ -310,7 +313,7 @@ func TestUpdateIPsUninitializedProbied(t *testing.T) {
 	}
 }
 
-//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable
 func TestUpdateIPsHints(t *testing.T) {
 	domain4 := domain.FQDN("ip4.hello")
 	domain6 := domain.FQDN("ip6.hello")
@@ -367,6 +370,7 @@ func TestUpdateIPsHints(t *testing.T) {
 			conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false}
 			conf.RecordComment = RecordComment
 			conf.Use1001 = true
+			conf.DetectionTimeout = time.Second
 			conf.UpdateTimeout = time.Second
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
@@ -398,7 +402,7 @@ func TestUpdateIPsHints(t *testing.T) {
 	}
 }
 
-//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable
 func TestUpdateIPs(t *testing.T) {
 	domain4 := domain.FQDN("ip4.hello")
 	domain6 := domain.FQDN("ip6.hello")
@@ -597,14 +601,37 @@ func TestUpdateIPs(t *testing.T) {
 			},
 			nil,
 		},
-		"slow-setting": {
+		"detect-timeout": {
+			false,
+			[]string{"Failed to detect IPv4 address"},
+			[]string{"Failed to detect the IPv4 address."},
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to detect the %s address", "IPv4"),
+					m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing DETECTION_TIMEOUT=%v", time.Second), //nolint:lll
+				)
+			},
+			mockproviders{
+				ipnet.IP4: func(ppfmt pp.PP, m *mocks.MockProvider) {
+					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4, true).
+						DoAndReturn(
+							func(context.Context, pp.PP, ipnet.Type, bool) (netip.Addr, bool) {
+								time.Sleep(2 * time.Second)
+								return netip.Addr{}, false
+							},
+						)
+				},
+			},
+			nil,
+		},
+		"set-timeout": {
 			false,
 			[]string{"Failed to set A (127.0.0.1): ip4.hello"},
 			[]string{"Failed to finish updating A records of ip4.hello with 127.0.0.1."},
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4),
-					m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing the value of UPDATE_TIMEOUT"), //nolint:lll
+					m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v", time.Second), //nolint:lll
 				)
 			},
 			mockproviders{
@@ -615,7 +642,7 @@ func TestUpdateIPs(t *testing.T) {
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false, RecordComment).
 					DoAndReturn(
-						func(_ context.Context, _ pp.PP, _ domain.Domain, _ ipnet.Type, _ netip.Addr, _ api.TTL, _ bool, _ string) setter.ResponseCode { //nolint:lll
+						func(context.Context, pp.PP, domain.Domain, ipnet.Type, netip.Addr, api.TTL, bool, string) setter.ResponseCode { //nolint:lll
 							time.Sleep(2 * time.Second)
 							return setter.ResponseFailed
 						})
@@ -631,6 +658,7 @@ func TestUpdateIPs(t *testing.T) {
 			conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false}
 			conf.RecordComment = RecordComment
 			conf.Use1001 = true
+			conf.DetectionTimeout = time.Second
 			conf.UpdateTimeout = time.Second
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
@@ -662,7 +690,7 @@ func TestUpdateIPs(t *testing.T) {
 	}
 }
 
-//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable
 func TestDeleteIPs(t *testing.T) {
 	domain4 := domain.FQDN("ip4.hello")
 	domain6 := domain.FQDN("ip6.hello")
@@ -769,6 +797,23 @@ func TestDeleteIPs(t *testing.T) {
 				)
 			},
 		},
+		"timeout": {
+			false,
+			[]string{"Failed to delete A: ip4.hello"},
+			[]string{"Failed to finish deleting A records of ip4.hello."},
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v", time.Second) //nolint:lll
+			},
+			mockproviders{ipnet.IP4: true},
+			func(ppfmt pp.PP, m *mocks.MockSetter) {
+				m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4).
+					DoAndReturn(
+						func(context.Context, pp.PP, domain.Domain, ipnet.Type) setter.ResponseCode {
+							time.Sleep(2 * time.Second)
+							return setter.ResponseFailed
+						})
+			},
+		},
 	} {
 		t.Run(name, func(t *testing.T) {
 			mockCtrl := gomock.NewController(t)
@@ -778,6 +823,9 @@ func TestDeleteIPs(t *testing.T) {
 			conf.TTL = api.TTLAuto
 			conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false}
 			conf.RecordComment = RecordComment
+			conf.Use1001 = true
+			conf.DetectionTimeout = time.Second
+			conf.UpdateTimeout = time.Second
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
 				tc.prepareMockPP(mockPP)
@@ -798,7 +846,6 @@ func TestDeleteIPs(t *testing.T) {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
 			resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
-
 			require.Equal(t, message.Message{
 				Ok:               tc.ok,
 				NotifierMessages: tc.notifierMessages,