Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions internal/dnsprovider/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,13 +467,54 @@ func jsonHTTPProviderRequest(ctx context.Context, method, endpoint, authorizatio
return request, nil
}

// APIError is a provider API response outside the 2xx range.
type APIError struct {
Provider string
StatusCode int
Status string
Body string
// RetryAfter is the wait the provider asked for in a Retry-After header,
// or zero when it named none.
RetryAfter time.Duration
}

func (apiError *APIError) Error() string {
return fmt.Sprintf("%s API returned %s: %s", apiError.Provider, apiError.Status, apiError.Body)
}

// Temporary reports whether the provider is rate limiting or briefly failing
// on its own side, so the same request is worth sending again later.
func (apiError *APIError) Temporary() bool {
return apiError.StatusCode == http.StatusTooManyRequests || apiError.StatusCode >= http.StatusInternalServerError
}

func responseError(provider string, response *http.Response) error {
contents, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
message := strings.TrimSpace(string(contents))
// One line per body keeps a joined error to one line per failed record.
message := strings.Join(strings.Fields(string(contents)), " ")
if message == "" {
message = response.Status
}
return fmt.Errorf("%s API returned %s: %s", provider, response.Status, message)
return &APIError{
Provider: provider, StatusCode: response.StatusCode, Status: response.Status,
Body: message, RetryAfter: retryAfter(response.Header.Get("Retry-After"), time.Now()),
}
}

// retryAfter reads a Retry-After header in either of its forms: a number of
// seconds or an HTTP date.
func retryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
return max(time.Duration(seconds)*time.Second, 0)
}
if at, err := http.ParseTime(value); err == nil {
return max(at.Sub(now), 0)
}
return 0
}

func relativeName(name, zone string) string {
Expand Down
50 changes: 50 additions & 0 deletions internal/dnsprovider/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,3 +503,53 @@ func TestNamecheapFailuresDoNotNameTheAPIKey(t *testing.T) {
t.Fatalf("error = %v", err)
}
}

func TestProviderAPIErrorsSayWhetherToRetry(t *testing.T) {
for _, test := range []struct {
status int
retryAfter string
temporary bool
wait time.Duration
}{
{status: http.StatusBadGateway, retryAfter: "60", temporary: true, wait: time.Minute},
{status: http.StatusTooManyRequests, temporary: true},
{status: http.StatusBadRequest},
} {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
response := httpResponse(test.status, `{"title":"failure"}`)
if test.retryAfter != "" {
response.Header.Set("Retry-After", test.retryAfter)
}
return response, nil
})}
provider := &cloudflareProvider{
credentials: Credentials{APIToken: "token", ZoneID: "zone-1"}, client: client, baseURL: "https://cloudflare.test/client/v4",
}
_, err := provider.EnsureRecord(context.Background(), Record{
Zone: "example.com", Name: "home.example.com", Type: TypeA, Value: "198.51.100.1", TTL: 300,
})
var apiError *APIError
if !errors.As(err, &apiError) {
t.Fatalf("status %d: error %v is not an APIError", test.status, err)
}
if apiError.StatusCode != test.status || apiError.Temporary() != test.temporary || apiError.RetryAfter != test.wait {
t.Errorf("status %d: APIError = %+v, temporary %v", test.status, apiError, apiError.Temporary())
}
}
}

func TestRetryAfterReadsSecondsAndDates(t *testing.T) {
now := time.Date(2026, time.October, 7, 14, 46, 37, 0, time.UTC)
for value, expected := range map[string]time.Duration{
"": 0,
"120": 2 * time.Minute,
"-5": 0,
"soon": 0,
"Wed, 07 Oct 2026 14:47:37 GMT": time.Minute,
"Wed, 07 Oct 2026 14:40:00 GMT": 0,
} {
if actual := retryAfter(value, now); actual != expected {
t.Errorf("retryAfter(%q) = %s, want %s", value, actual, expected)
}
}
}
48 changes: 35 additions & 13 deletions internal/dynamicdns/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ type reconcileResult struct {
ipv6 string
changed int
unchanged int
// retryAfter is the longest wait a provider asked for before the next
// attempt.
retryAfter time.Duration
}

func (manager *Manager) reconcile(ctx context.Context, settings config.DynamicDNS) (reconcileResult, error) {
Expand Down Expand Up @@ -323,26 +326,41 @@ func (manager *Manager) reconcile(ctx context.Context, settings config.DynamicDN
publicationErrors = append(publicationErrors, fmt.Errorf("initialize %s provider: %w", configuredPublisher.Provider, providerErr))
continue
}
for _, configured := range configuredPublisher.Records {
for _, recordType := range recordTypes(configured) {
address := addresses[recordType]
changed, ensureErr := publisher.EnsureRecord(ctx, dnsprovider.Record{
Zone: configured.Zone, Name: configured.Name, Type: recordType,
Value: address.String(), TTL: configured.TTL,
})
if ensureErr != nil {
publicationErrors = append(publicationErrors, fmt.Errorf("%s: publish %s %s: %w", configuredPublisher.Provider, configured.Name, recordType, ensureErr))
continue
}
if err := publishRecords(ctx, configuredPublisher, publisher, addresses, &result); err != nil {
publicationErrors = append(publicationErrors, err)
}
}
return result, errors.Join(publicationErrors...)
}

// publishRecords ensures every record of one publisher. A provider that is rate
// limiting or failing on its own side gets no more requests this attempt:
// the rest would fail the same way, and the retry backoff covers them all.
func publishRecords(ctx context.Context, configured config.DynamicDNSPublisher, publisher provider, addresses map[string]netip.Addr, result *reconcileResult) error {
var publicationErrors []error
for _, record := range configured.Records {
for _, recordType := range recordTypes(record) {
changed, err := publisher.EnsureRecord(ctx, dnsprovider.Record{
Zone: record.Zone, Name: record.Name, Type: recordType,
Value: addresses[recordType].String(), TTL: record.TTL,
})
if err == nil {
if changed {
result.changed++
} else {
result.unchanged++
}
continue
}
publicationErrors = append(publicationErrors, fmt.Errorf("%s: publish %s %s: %w", configured.Provider, record.Name, recordType, err))
var apiError *dnsprovider.APIError
if errors.As(err, &apiError) && apiError.Temporary() {
result.retryAfter = max(result.retryAfter, apiError.RetryAfter)
return errors.Join(publicationErrors...)
}
}
}
return result, errors.Join(publicationErrors...)
return errors.Join(publicationErrors...)
}

func (manager *Manager) discoverAddresses(ctx context.Context, settings config.DynamicDNS) (reconcileResult, map[string]netip.Addr, error) {
Expand Down Expand Up @@ -494,7 +512,11 @@ func (manager *Manager) finishAttempt(started time.Time, interval time.Duration,
}
manager.status.ConsecutiveFailures++
manager.status.LastError = err.Error()
manager.status.NextAttempt = started.Add(retryDelay(manager.status.ConsecutiveFailures, interval))
delay := retryDelay(manager.status.ConsecutiveFailures, interval)
if result.retryAfter > delay {
delay = min(result.retryAfter, maximumRetry)
}
manager.status.NextAttempt = started.Add(delay)
}

func retryDelay(failures int, interval time.Duration) time.Duration {
Expand Down
51 changes: 51 additions & 0 deletions internal/dynamicdns/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,54 @@ func testDynamicDNSSettings() config.DynamicDNS {
}},
}
}

func TestReconcileStopsCallingAProviderThatIsFailingOnItsSide(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
err *dnsprovider.APIError
attempts int
wantRetry time.Duration
}{
{name: "a 502 stops after the first record", err: &dnsprovider.APIError{StatusCode: 502}, attempts: 1, wantRetry: 30 * time.Second},
{name: "a 429 waits as long as the provider asks", err: &dnsprovider.APIError{StatusCode: 429, RetryAfter: 2 * time.Minute}, attempts: 1, wantRetry: 2 * time.Minute},
{name: "a 400 still tries every record", err: &dnsprovider.APIError{StatusCode: 400, RetryAfter: time.Hour}, attempts: 3, wantRetry: 30 * time.Second},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
settings := config.DynamicDNS{
Enabled: true, Interval: config.Duration{Duration: 5 * time.Minute},
IPv4URL: "https://ipv4.test", IPv6URL: "https://ipv6.test",
Publishers: []config.DynamicDNSPublisher{
{Provider: "cloudflare", Records: []config.DynamicDNSRecord{
{Zone: "example.com", Name: "home.example.com", IPv4: true, IPv6: true, TTL: 300},
{Zone: "example.com", Name: "vpn.example.com", IPv4: true, TTL: 300},
}},
{Provider: "route53", Records: []config.DynamicDNSRecord{{Zone: "example.net", Name: "edge.example.net", IPv4: true, TTL: 300}}},
},
}
providers := map[string]*testProvider{"cloudflare": {err: test.err}, "route53": {changed: true}}
manager := newTestManager(settings, providers["cloudflare"])
manager.newProvider = func(name string, _ dnsprovider.Credentials) (provider, error) { return providers[name], nil }
manager.discover = func(_ context.Context, _ string, recordType string) (netip.Addr, error) {
if recordType == dnsprovider.TypeA {
return netip.MustParseAddr("8.8.8.8"), nil
}
return netip.MustParseAddr("2001:4860:4860::8888"), nil
}

manager.runOnce(context.Background())

if attempts := len(providers["cloudflare"].records); attempts != test.attempts {
t.Fatalf("Cloudflare requests = %d, want %d", attempts, test.attempts)
}
if len(providers["route53"].records) != 1 {
t.Fatalf("Route 53 requests = %d, want 1", len(providers["route53"].records))
}
status := manager.Status(context.Background())
if retry := status.NextAttempt.Sub(status.LastAttempt); retry != test.wantRetry {
t.Fatalf("next attempt in %s, want %s", retry, test.wantRetry)
}
})
}
}
3 changes: 2 additions & 1 deletion internal/web/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3293,9 +3293,10 @@ button.top-stats-row { width: 100%; border-width: 0 0 1px; background: none; col
.unifi-status.warning { border-color: color-mix(in srgb, var(--warning-strong) 45%, var(--border)); background: color-mix(in srgb, var(--warning-strong) 8%, transparent); }
.unifi-status.warning > .nav-icon { color: var(--warning-strong); }
.unifi-status > .nav-icon { width: 1rem; height: 1rem; flex: 0 0 1rem; margin-top: .1rem; }
.unifi-status > div { display: grid; gap: .2rem; }
.unifi-status > div { display: grid; min-width: 0; gap: .2rem; }
.unifi-status strong { font-size: .75rem; }
.unifi-status span { color: var(--muted-foreground); font-size: .7rem; line-height: 1.45; }
.integration-error-summary { white-space: pre-line; overflow-wrap: anywhere; }
.integration-error-details { min-width: 0; margin-top: .15rem; color: var(--muted-foreground); font-size: .68rem; }
.integration-error-details summary { width: max-content; cursor: pointer; color: var(--foreground); }
.integration-error-details pre { max-width: 100%; max-height: 12rem; overflow: auto; margin: var(--space-2) 0 0; border: 1px solid var(--border); border-radius: .4rem; background: var(--background); padding: .65rem; color: var(--muted-foreground); font-size: .65rem; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
Expand Down
Loading
Loading