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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 08:45:52 +00:00
1 parent 5295c932c0
commit 4fbfc4586b
2 files changed
+96 -4

No files matched your search

+14 -4
View File
@@ -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
@@ -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))
})
})