Files
LocalAI/core/services/failover/classify_test.go
T
Ettore Di Giacinto 882f51fd62 fix(failover): trip a rate-limited or exhausted target instead of skipping it
Treating ResourceExhausted as a capability gap skipped the target
without counting a failure, so a target that stays rate limited or out
of memory kept its traffic. It is now an ordinary retryable failure:
the request moves to the next target and the exhausted one trips.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 07:42:21 +00:00

53 lines
2.5 KiB
Go

package failover
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"
)
var _ = DescribeTable("IsRetryable",
func(err error, status int, want bool) {
Expect(IsRetryable(err, status)).To(Equal(want))
},
Entry("nil error, no status", nil, 0, false),
Entry("held 503", nil, http.StatusServiceUnavailable, true),
Entry("held 500", nil, http.StatusInternalServerError, true),
Entry("held 501", nil, http.StatusNotImplemented, false),
Entry("client cancel", context.Canceled, 0, false),
Entry("wrapped client cancel", fmt.Errorf("predict: %w", context.Canceled), 0, false),
Entry("deadline", context.DeadlineExceeded, 0, true),
Entry("echo 502", echo.NewHTTPError(http.StatusBadGateway, "x"), 0, true),
Entry("echo 400", echo.NewHTTPError(http.StatusBadRequest, "x"), 0, false),
Entry("echo 404", echo.NewHTTPError(http.StatusNotFound, "x"), 0, false),
Entry("grpc unavailable", grpcstatus.Error(codes.Unavailable, "x"), 0, true),
Entry("grpc internal", grpcstatus.Error(codes.Internal, "x"), 0, true),
Entry("grpc deadline", grpcstatus.Error(codes.DeadlineExceeded, "x"), 0, true),
Entry("grpc unknown", grpcstatus.Error(codes.Unknown, "x"), 0, true),
Entry("grpc invalid argument", grpcstatus.Error(codes.InvalidArgument, "x"), 0, false),
Entry("grpc resource exhausted (rate limit, OOM)", grpcstatus.Error(codes.ResourceExhausted, "x"), 0, true),
Entry("cloud-proxy upstream 503", errors.New("cloud-proxy: upstream 503: no healthy nodes"), 0, true),
Entry("cloud-proxy upstream 429 stays 4xx", errors.New("cloud-proxy: upstream 429: slow down"), 0, false),
Entry("context overflow", errors.New("the request exceeds the available context size"), 0, false),
Entry("dial error", errors.New("dial tcp 10.0.0.1:8080: connect: connection refused"), 0, true),
)
var _ = DescribeTable("IsCapabilityGap",
func(err error, want bool) {
Expect(IsCapabilityGap(err)).To(Equal(want))
},
Entry("nil error", nil, false),
Entry("grpc unimplemented", grpcstatus.Error(codes.Unimplemented, "x"), true),
Entry("wrapped grpc unimplemented", fmt.Errorf("call: %w", grpcstatus.Error(codes.Unimplemented, "x")), true),
Entry("grpc resource exhausted is a failure, not a gap", grpcstatus.Error(codes.ResourceExhausted, "x"), false),
Entry("grpc unavailable", grpcstatus.Error(codes.Unavailable, "x"), false),
Entry("plain error", errors.New("boom"), false),
)