diff --git a/internal/cnpgi/common/errors.go b/internal/cnpgi/common/errors.go index 841137d..2aa1836 100644 --- a/internal/cnpgi/common/errors.go +++ b/internal/cnpgi/common/errors.go @@ -20,10 +20,38 @@ SPDX-License-Identifier: Apache-2.0 package common import ( + "errors" + + barmanRestorer "github.com/cloudnative-pg/barman-cloud/pkg/restorer" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) +// classifyWALRestoreError maps an error returned by the WAL +// restorer to a gRPC-coded error so the caller can tell terminal +// failures apart from transient ones via the status code. +func classifyWALRestoreError(walName string, walErr error) error { + switch { + case errors.Is(walErr, barmanRestorer.ErrWALNotFound): + return newWALNotFoundError(walName) + case errors.Is(walErr, barmanRestorer.ErrInvalidWalName): + // A malformed WAL name will never become valid on retry. + return newInvalidWALNameError(walName, walErr) + case errors.Is(walErr, barmanRestorer.ErrConnectivity), + errors.Is(walErr, barmanRestorer.ErrGeneric): + // barman-cloud exit codes 2 (connectivity) and 4 + // (generic) both surface conditions that are retryable + // in practice — barman uses the "generic" bucket for + // some connection-class failures too, not just exit 2. + return newUnavailableError(walName, walErr) + default: + // Unrecognized exit codes and unexpected failures (e.g. + // the barman-cloud command could not be executed). No + // positive signal that retry would help. + return newInternalWALRestoreError(walName, walErr) + } +} + // ErrEndOfWALStreamReached is returned when end of WAL is detected in the cloud archive. var ErrEndOfWALStreamReached = status.Errorf(codes.OutOfRange, "end of WAL reached") diff --git a/internal/cnpgi/common/errors_test.go b/internal/cnpgi/common/errors_test.go new file mode 100644 index 0000000..ac3d509 --- /dev/null +++ b/internal/cnpgi/common/errors_test.go @@ -0,0 +1,86 @@ +/* +Copyright © contributors to CloudNativePG, established as +CloudNativePG a Series of LF Projects, LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package common + +import ( + "errors" + "fmt" + + barmanRestorer "github.com/cloudnative-pg/barman-cloud/pkg/restorer" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var _ = Describe("classifyWALRestoreError", func() { + const walName = "000000010000000000000001" + + DescribeTable( + "maps barman restorer sentinels to gRPC status codes", + func(walErr error, expectedCode codes.Code) { + got := classifyWALRestoreError(walName, walErr) + Expect(got).To(HaveOccurred()) + + st, ok := status.FromError(got) + Expect(ok).To(BeTrue(), "returned error must carry a gRPC status") + Expect(st.Code()).To(Equal(expectedCode)) + Expect(st.Message()).To(ContainSubstring(walName)) + }, + Entry("ErrWALNotFound -> NotFound", + fmt.Errorf("object storage or file not found: %w", barmanRestorer.ErrWALNotFound), + codes.NotFound), + Entry("ErrInvalidWalName -> InvalidArgument", + fmt.Errorf("invalid name for a WAL file: %w", barmanRestorer.ErrInvalidWalName), + codes.InvalidArgument), + Entry("ErrConnectivity -> Unavailable", + fmt.Errorf("connectivity failure, retrying: %w", barmanRestorer.ErrConnectivity), + codes.Unavailable), + Entry("ErrGeneric -> Unavailable (barman uses exit 4 for some retryable cases too)", + fmt.Errorf("generic error: %w", barmanRestorer.ErrGeneric), + codes.Unavailable), + Entry("unknown error -> Internal", + errors.New("something we did not classify"), + codes.Internal), + ) + + It("matches the sentinel even through several wrapping layers", func() { + // The plugin wraps barman errors via fmt.Errorf("...: %w", ...); + // classification must keep working if more wraps appear above. + inner := fmt.Errorf("connectivity failure, retrying: %w", barmanRestorer.ErrConnectivity) + wrapped := fmt.Errorf("while restoring WAL %s: %w", walName, inner) + + got := classifyWALRestoreError(walName, wrapped) + st, ok := status.FromError(got) + Expect(ok).To(BeTrue()) + Expect(st.Code()).To(Equal(codes.Unavailable)) + }) + + It("treats ErrWALNotFound as terminal even when the error chain mentions other sentinels in its message", func() { + // Defensive: if the underlying error stringifies to something + // resembling another sentinel's message, the switch must still + // match by identity (errors.Is), not by substring. + walErr := fmt.Errorf("not found, looks like a connectivity failure: %w", barmanRestorer.ErrWALNotFound) + + got := classifyWALRestoreError(walName, walErr) + st, _ := status.FromError(got) + Expect(st.Code()).To(Equal(codes.NotFound)) + }) +}) diff --git a/internal/cnpgi/common/suite_test.go b/internal/cnpgi/common/suite_test.go new file mode 100644 index 0000000..52a9375 --- /dev/null +++ b/internal/cnpgi/common/suite_test.go @@ -0,0 +1,32 @@ +/* +Copyright © contributors to CloudNativePG, established as +CloudNativePG a Series of LF Projects, LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package common + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCommon(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Common Suite") +} diff --git a/internal/cnpgi/common/wal.go b/internal/cnpgi/common/wal.go index 489f71b..7ccc55d 100644 --- a/internal/cnpgi/common/wal.go +++ b/internal/cnpgi/common/wal.go @@ -365,29 +365,7 @@ func (w WALServiceImplementation) restoreFromBarmanObjectStore( // is the one that PostgreSQL has requested to restore. // The failure has already been logged in walRestorer.RestoreList method if walStatus[0].Err != nil { - walName := walStatus[0].WalName - walErr := walStatus[0].Err - switch { - case errors.Is(walErr, barmanRestorer.ErrWALNotFound): - return newWALNotFoundError(walName) - case errors.Is(walErr, barmanRestorer.ErrInvalidWalName): - // A malformed WAL name will never become valid on retry. - return newInvalidWALNameError(walName, walErr) - case errors.Is(walErr, barmanRestorer.ErrConnectivity), - errors.Is(walErr, barmanRestorer.ErrGeneric): - // barman-cloud exit codes 2 (connectivity) and 4 - // (generic) both surface conditions that are retryable - // in practice — barman uses the "generic" bucket for - // some connection-class failures too, not just exit 2. - // Emit codes.Unavailable so the caller retries. - return newUnavailableError(walName, walErr) - default: - // Unrecognized exit codes and unexpected failures - // (e.g. the barman-cloud command could not be - // executed). No positive signal that retry would - // help; emit codes.Internal. - return newInternalWALRestoreError(walName, walErr) - } + return classifyWALRestoreError(walStatus[0].WalName, walStatus[0].Err) } // We skip this step if streaming connection is not available