fix(api): distinguish domain-not-found (404) from provider failure (502) on zone endpoints

Introduce service.ErrProviderUnavailable, wrapped only around the
provider GetRecords call in ZoneRecords. handleZoneRecords and
handleTemplateFromZone now use errors.Is against it to tell a real
provider outage (502) apart from local resolution failures such as an
unknown domain (404), instead of collapsing every ZoneRecords error
into a blanket 502. Also fixes handleTemplateFromZone's GetDomain
error branch to return 404 "domain not found" instead of 500, for
consistency with handleSetDomainTemplate/handleDomainHistory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-05 12:14:46 +07:00
parent 9ccb304d2e
commit 5662334799
3 changed files with 53 additions and 7 deletions
+9 -1
View File
@@ -86,7 +86,11 @@ func (a *API) handleZoneRecords(w http.ResponseWriter, r *http.Request) {
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did) recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil { if err != nil {
log.Printf("api: zone records failed: %v", err) log.Printf("api: zone records failed: %v", err)
if errors.Is(err, service.ErrProviderUnavailable) {
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
} else {
writeErr(w, http.StatusNotFound, "домен не найден")
}
return return
} }
doc := dto.FromModel(recs) doc := dto.FromModel(recs)
@@ -107,13 +111,17 @@ func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
dom, err := a.Store.GetDomain(r.Context(), did, pid) dom, err := a.Store.GetDomain(r.Context(), did, pid)
if err != nil { if err != nil {
log.Printf("api: template-from-zone: get domain failed: %v", err) log.Printf("api: template-from-zone: get domain failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error") writeErr(w, http.StatusNotFound, "домен не найден")
return return
} }
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did) recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil { if err != nil {
log.Printf("api: template-from-zone: zone records failed: %v", err) log.Printf("api: template-from-zone: zone records failed: %v", err)
if errors.Is(err, service.ErrProviderUnavailable) {
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
} else {
writeErr(w, http.StatusNotFound, "домен не найден")
}
return return
} }
// Snapshot only managed records — NS/SOA are read-only and never templated. // Snapshot only managed records — NS/SOA are read-only and never templated.
+25 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -14,6 +15,7 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto" "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
) )
@@ -688,13 +690,13 @@ func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
} }
// TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path: // TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path:
// a GetRecords error from the provider must surface as 502 (bad gateway), // an error wrapping service.ErrProviderUnavailable (i.e. GetRecords itself
// not a generic 500 or a hung response. // failed) must surface as 502 (bad gateway), not a generic 500 or 404.
func TestZoneRecords_ProviderErrorReturns502(t *testing.T) { func TestZoneRecords_ProviderErrorReturns502(t *testing.T) {
a, ts := newTenantTestAPI() a, ts := newTenantTestAPI()
domID := uuid.New() domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}} ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneErr: errors.New("provider unreachable")} a.Svc = &mockCheckApplier{zoneErr: fmt.Errorf("%w: boom", service.ErrProviderUnavailable)}
router := NewRouter(a) router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil) req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
@@ -705,3 +707,23 @@ func TestZoneRecords_ProviderErrorReturns502(t *testing.T) {
t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String()) t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String())
} }
} }
// TestZoneRecords_NotFoundReturns404 covers the domain-resolution-failure
// path: an error that does NOT wrap service.ErrProviderUnavailable (e.g. the
// domain doesn't exist in this project) must surface as 404, not 502 — the
// provider was never even reached.
func TestZoneRecords_NotFoundReturns404(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneErr: errors.New("not found")}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
}
}
+17 -1
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"errors"
"fmt"
"github.com/google/uuid" "github.com/google/uuid"
@@ -13,6 +15,13 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/store/dto" "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
) )
// ErrProviderUnavailable marks failures that happened while talking to the
// DNS provider itself (network, auth, rate limit, ...), as opposed to
// failures resolving the domain/zone locally (not found, bad credentials
// stored, unknown provider name). Callers use errors.Is against this to
// pick 502 vs 404 without leaking provider error details as "not found".
var ErrProviderUnavailable = errors.New("service: provider unavailable")
// DomainRef is the minimal data the service needs about a domain. // DomainRef is the minimal data the service needs about a domain.
type DomainRef struct { type DomainRef struct {
ZoneID string ZoneID string
@@ -107,7 +116,14 @@ func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uui
if err != nil { if err != nil {
return nil, err return nil, err
} }
return p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID) recs, err := p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
if err != nil {
// Only a failure of the provider call itself is "provider unavailable" —
// LoadZone/ByName/Decrypt errors above are local resolution failures
// (e.g. domain not found) and must not be conflated with it.
return nil, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
}
return recs, nil
} }
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes. // Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.