From 4fbfc4586b22586abc25e877451fd54b20c04467 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 08:15:09 +0000 Subject: [PATCH] fix(addons): keep the cause of a connector failure localConnectorCall and the kinds lookup turned every error from the connector source into a bare ErrUnavailable. VerifyTokenWithMe treats that as "could not tell" and saves the binding, so a definite refusal by the add-on was indistinguishable from an outage, and the cause was lost. An add-on refusal (invalid request, or denied for this person) now keeps its type through Refused. Other errors still read as ErrUnavailable but wrap the cause, so errors.Is and logs see it. The forwarding carrier maps the owner's 400 back to ErrInvalidRequest instead of ErrUnavailable. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/services/addons/gateway/connectors.go | 18 +++- .../addons/gateway/connectors_test.go | 82 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 core/services/addons/gateway/connectors_test.go diff --git a/core/services/addons/gateway/connectors.go b/core/services/addons/gateway/connectors.go index 9fdd7877f3..870bd2094f 100644 --- a/core/services/addons/gateway/connectors.go +++ b/core/services/addons/gateway/connectors.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "time" @@ -56,7 +57,7 @@ func (g *Gateway) localConnectorKinds(ctx context.Context) ([]connectors.Kind, e } kinds, err := source.ConnectorKinds(ctx) if err != nil { - return nil, ErrUnavailable + return nil, fmt.Errorf("%w: %w", ErrUnavailable, err) } out := make([]connectors.Kind, len(kinds)) for i, k := range kinds { @@ -86,7 +87,7 @@ func (g *Gateway) localConnectorCall(ctx context.Context, s connectors.Connector } kinds, err := source.ConnectorKinds(ctx) if err != nil { - return contract.ConnectorResponse{}, ErrUnavailable + return contract.ConnectorResponse{}, fmt.Errorf("%w: %w", ErrUnavailable, err) } found := false for _, k := range kinds { @@ -111,7 +112,12 @@ func (g *Gateway) localConnectorCall(ctx context.Context, s connectors.Connector } out, err := source.Connector(ctx, s.AddonID, req) if err != nil { - return contract.ConnectorResponse{}, ErrUnavailable + // A definite refusal of the add-on keeps its type; anything else could + // not tell, so the caller treats it as an outage, with the cause kept. + if refused := Refused(err); !errors.Is(refused, ErrUnavailable) && errors.Is(refused, ErrForbidden) { + return contract.ConnectorResponse{}, refused + } + return contract.ConnectorResponse{}, fmt.Errorf("%w: %w", ErrUnavailable, err) } return out, nil } @@ -136,8 +142,12 @@ func (c *Carrier) connectorRequest(ctx context.Context, method, path string, bod return ErrUnavailable } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode == http.StatusForbidden { + switch resp.StatusCode { + case http.StatusForbidden: return ErrForbidden + case http.StatusBadRequest: + // The owner relays the add-on's definite refusal; see typedError. + return ErrInvalidRequest } if resp.StatusCode != http.StatusOK || decodeConnector(resp.Body, out) != nil { return ErrUnavailable diff --git a/core/services/addons/gateway/connectors_test.go b/core/services/addons/gateway/connectors_test.go new file mode 100644 index 0000000000..7978246865 --- /dev/null +++ b/core/services/addons/gateway/connectors_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +package gateway + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeConnectorSource struct{ kindsErr, callErr error } + +func (s fakeConnectorSource) ConnectorKinds(context.Context) ([]addons.ConnectorKind, error) { + if s.kindsErr != nil { + return nil, s.kindsErr + } + return []addons.ConnectorKind{{AddonID: "slack", ConnectorKind: contract.ConnectorKind{ID: "slack", Delivery: contract.DeliveryPush}}}, nil +} + +func (s fakeConnectorSource) Connector(context.Context, string, contract.ConnectorRequest) (contract.ConnectorResponse, error) { + return contract.ConnectorResponse{}, s.callErr +} + +type allowAllConnectors struct{} + +func (allowAllConnectors) AuthorizeConnector(_ context.Context, _ connectors.ConnectorCallScope, r contract.ConnectorRequest) (contract.ConnectorRequest, error) { + return r, nil +} + +var _ = Describe("Connector errors", func() { + scope := connectors.ConnectorCallScope{AddonID: "slack", Kind: "slack"} + call := func(src fakeConnectorSource) error { + g := &Gateway{Connectors: src, ConnectorAuthority: allowAllConnectors{}} + _, err := g.ConnectorCall(context.Background(), scope, contract.ConnectorRequest{Op: contract.ConnectorOpMe, Kind: "slack"}) + return err + } + + It("keeps the cause of an outage and still reports it as unavailable", func() { + cause := errors.New("socket closed") + err := call(fakeConnectorSource{callErr: cause}) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(err).To(MatchError(cause)) + + _, err = (&Gateway{Connectors: fakeConnectorSource{kindsErr: cause}}).ConnectorKinds(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(err).To(MatchError(cause)) + }) + + It("tells a definite refusal of the add-on apart from an outage", func() { + err := call(fakeConnectorSource{callErr: fmt.Errorf("runtime: %w", contract.ErrRefused)}) + Expect(err).To(MatchError(ErrInvalidRequest)) + Expect(err).NotTo(MatchError(ErrUnavailable)) + + err = call(fakeConnectorSource{callErr: fmt.Errorf("runtime: %w", contract.ErrDenied)}) + Expect(err).To(MatchError(ErrNotPermitted)) + Expect(err).NotTo(MatchError(ErrUnavailable)) + }) +}) + +var _ = Describe("Forwarded connector answers", func() { + forwarded := func(status int) error { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })) + defer server.Close() + c := &Carrier{config: CarrierConfig{OwnerAddress: server.URL}, client: server.Client()} + return c.connectorRequest(context.Background(), "GET", "/addons/connectors/kinds", nil, &[]connectors.Kind{}) + } + + It("keeps a refusal apart from an outage", func() { + Expect(forwarded(http.StatusBadRequest)).To(MatchError(ErrInvalidRequest)) + Expect(forwarded(http.StatusForbidden)).To(MatchError(ErrForbidden)) + err := forwarded(http.StatusServiceUnavailable) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(err).NotTo(MatchError(ErrForbidden)) + }) +})