fix(provider): handle invalid proxy upstreams safely

Avoid panicking when a ProxyProvider is constructed with a malformed upstream URL. The provider now records the configuration error and returns a standard 500 envelope when mounted.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 21:42:04 +00:00
parent 475027d716
commit 2fd17a432c
2 changed files with 58 additions and 8 deletions

View file

@ -8,6 +8,7 @@ import (
"net/url"
"strings"
coreapi "dappco.re/go/core/api"
"github.com/gin-gonic/gin"
)
@ -39,14 +40,20 @@ type ProxyConfig struct {
type ProxyProvider struct {
config ProxyConfig
proxy *httputil.ReverseProxy
err error
}
// NewProxy creates a ProxyProvider from the given configuration.
// The upstream URL must be valid or NewProxy will panic.
// Invalid upstream URLs do not panic; the provider retains the
// configuration error and responds with a standard 500 envelope when
// mounted. This keeps provider construction safe for callers.
func NewProxy(cfg ProxyConfig) *ProxyProvider {
target, err := url.Parse(cfg.Upstream)
if err != nil {
panic("provider.NewProxy: invalid upstream URL: " + err.Error())
return &ProxyProvider{
config: cfg,
err: err,
}
}
proxy := httputil.NewSingleHostReverseProxy(target)
@ -71,6 +78,15 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider {
}
}
// Err reports any configuration error detected while constructing the proxy.
// A nil error means the proxy is ready to mount and serve requests.
func (p *ProxyProvider) Err() error {
if p == nil {
return nil
}
return p.err
}
// stripBasePath removes an exact base path prefix from a request path.
// It only strips when the path matches the base path itself or lives under
// the base path boundary, so "/api" will not accidentally trim "/api-v2".
@ -112,6 +128,19 @@ func (p *ProxyProvider) BasePath() string {
// RegisterRoutes mounts a catch-all reverse proxy handler on the router group.
func (p *ProxyProvider) RegisterRoutes(rg *gin.RouterGroup) {
rg.Any("/*path", func(c *gin.Context) {
if p == nil || p.err != nil || p.proxy == nil {
details := map[string]any{}
if p != nil && p.err != nil {
details["error"] = p.err.Error()
}
c.JSON(http.StatusInternalServerError, coreapi.FailWithDetails(
"invalid_provider_configuration",
"Provider is misconfigured",
details,
))
return
}
// Use the underlying http.ResponseWriter directly. Gin's
// responseWriter wrapper does not implement http.CloseNotifier,
// which httputil.ReverseProxy requires for cancellation signalling.

View file

@ -183,11 +183,32 @@ func TestProxyProvider_Renderable_Good(t *testing.T) {
}
func TestProxyProvider_Ugly_InvalidUpstream(t *testing.T) {
assert.Panics(t, func() {
provider.NewProxy(provider.ProxyConfig{
Name: "bad",
BasePath: "/api/v1/bad",
Upstream: "://not-a-url",
})
p := provider.NewProxy(provider.ProxyConfig{
Name: "bad",
BasePath: "/api/v1/bad",
Upstream: "://not-a-url",
})
require.NotNil(t, p)
assert.Error(t, p.Err())
engine, err := api.New()
require.NoError(t, err)
engine.Register(p)
handler := engine.Handler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/bad/items", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, false, body["success"])
errObj, ok := body["error"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "invalid_provider_configuration", errObj["code"])
}