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:
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user