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)) + }) +})