Skip to content

Commit b6e4886

Browse files
sd2kclaude
andauthored
fix: fallback between /resources and /proxy datasource endpoints (#562)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1b56d7d commit b6e4886

4 files changed

Lines changed: 340 additions & 5 deletions

File tree

tools/fallback_transport.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package tools
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"sync"
10+
)
11+
12+
// datasourceFallbackTransport is an http.RoundTripper that tries a primary
13+
// datasource proxy URL path and falls back to an alternate on 403 or 500
14+
// responses. This handles compatibility between different Grafana deployments:
15+
// - Azure Managed Grafana requires /api/datasources/uid/{uid}/resources
16+
// - AWS Managed Grafana requires /api/datasources/proxy/uid/{uid}
17+
//
18+
// See https://github.com/grafana/mcp-grafana/issues/524
19+
type datasourceFallbackTransport struct {
20+
wrapped http.RoundTripper
21+
primaryBase string // e.g., "/api/datasources/uid/{uid}/resources"
22+
fallbackBase string // e.g., "/api/datasources/proxy/uid/{uid}"
23+
}
24+
25+
// fallbackEndpoints caches which datasource proxy paths need the fallback
26+
// endpoint. Key is the primary base path, value is true if fallback is needed.
27+
var fallbackEndpoints sync.Map
28+
29+
func newDatasourceFallbackTransport(wrapped http.RoundTripper, primaryBase, fallbackBase string) http.RoundTripper {
30+
return &datasourceFallbackTransport{
31+
wrapped: wrapped,
32+
primaryBase: primaryBase,
33+
fallbackBase: fallbackBase,
34+
}
35+
}
36+
37+
func (t *datasourceFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) {
38+
// Check cache: if we already know the fallback works, use it directly.
39+
if useFallback, ok := fallbackEndpoints.Load(t.primaryBase); ok && useFallback.(bool) {
40+
return t.wrapped.RoundTrip(t.rewriteRequest(req, t.primaryBase, t.fallbackBase))
41+
}
42+
43+
// Buffer the request body so we can replay it on retry.
44+
var bodyBytes []byte
45+
if req.Body != nil {
46+
var err error
47+
bodyBytes, err = io.ReadAll(req.Body)
48+
req.Body.Close() //nolint:errcheck
49+
if err != nil {
50+
return nil, fmt.Errorf("buffering request body for fallback: %w", err)
51+
}
52+
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
53+
}
54+
55+
resp, err := t.wrapped.RoundTrip(req)
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusInternalServerError {
61+
return resp, nil
62+
}
63+
64+
// Got 403 or 500 — try the fallback endpoint.
65+
resp.Body.Close() //nolint:errcheck
66+
67+
retryReq := t.rewriteRequest(req, t.primaryBase, t.fallbackBase)
68+
if bodyBytes != nil {
69+
retryReq.Body = io.NopCloser(bytes.NewReader(bodyBytes))
70+
retryReq.ContentLength = int64(len(bodyBytes))
71+
}
72+
73+
retryResp, retryErr := t.wrapped.RoundTrip(retryReq)
74+
if retryErr != nil {
75+
return nil, retryErr
76+
}
77+
78+
// If the fallback succeeded, remember it for future requests.
79+
if retryResp.StatusCode != http.StatusForbidden && retryResp.StatusCode != http.StatusInternalServerError {
80+
fallbackEndpoints.Store(t.primaryBase, true)
81+
}
82+
83+
return retryResp, nil
84+
}
85+
86+
func (t *datasourceFallbackTransport) rewriteRequest(req *http.Request, from, to string) *http.Request {
87+
clone := req.Clone(req.Context())
88+
clone.URL.Path = strings.Replace(clone.URL.Path, from, to, 1)
89+
if clone.URL.RawPath != "" {
90+
clone.URL.RawPath = strings.Replace(clone.URL.RawPath, from, to, 1)
91+
}
92+
return clone
93+
}
94+
95+
// datasourceProxyPaths returns the /resources and /proxy base paths for a
96+
// given datasource UID.
97+
func datasourceProxyPaths(uid string) (resourcesBase, proxyBase string) {
98+
resourcesBase = fmt.Sprintf("/api/datasources/uid/%s/resources", uid)
99+
proxyBase = fmt.Sprintf("/api/datasources/proxy/uid/%s", uid)
100+
return
101+
}

tools/fallback_transport_test.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package tools
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func resetFallbackCache() {
14+
fallbackEndpoints.Range(func(key, _ any) bool {
15+
fallbackEndpoints.Delete(key)
16+
return true
17+
})
18+
}
19+
20+
// mockTransport records requests and returns canned responses based on URL path.
21+
type mockTransport struct {
22+
responses map[string]*http.Response // path prefix -> response
23+
requests []*http.Request
24+
}
25+
26+
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
27+
m.requests = append(m.requests, req)
28+
for prefix, resp := range m.responses {
29+
if strings.Contains(req.URL.Path, prefix) {
30+
return resp, nil
31+
}
32+
}
33+
return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("not found"))}, nil
34+
}
35+
36+
func newMockResponse(status int) *http.Response {
37+
return &http.Response{
38+
StatusCode: status,
39+
Body: io.NopCloser(strings.NewReader("")),
40+
}
41+
}
42+
43+
func TestDatasourceFallbackTransport_PrimarySucceeds(t *testing.T) {
44+
resetFallbackCache()
45+
46+
mock := &mockTransport{
47+
responses: map[string]*http.Response{
48+
"/api/datasources/uid/test-uid/resources": newMockResponse(http.StatusOK),
49+
},
50+
}
51+
52+
rt := newDatasourceFallbackTransport(mock,
53+
"/api/datasources/uid/test-uid/resources",
54+
"/api/datasources/proxy/uid/test-uid",
55+
)
56+
57+
req, _ := http.NewRequest("POST", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/query", nil)
58+
resp, err := rt.RoundTrip(req)
59+
60+
require.NoError(t, err)
61+
assert.Equal(t, http.StatusOK, resp.StatusCode)
62+
assert.Len(t, mock.requests, 1, "should not retry when primary succeeds")
63+
}
64+
65+
func TestDatasourceFallbackTransport_FallbackOn403(t *testing.T) {
66+
resetFallbackCache()
67+
68+
mock := &mockTransport{
69+
responses: map[string]*http.Response{
70+
"/api/datasources/uid/test-uid/resources": newMockResponse(http.StatusForbidden),
71+
"/api/datasources/proxy/uid/test-uid": newMockResponse(http.StatusOK),
72+
},
73+
}
74+
75+
rt := newDatasourceFallbackTransport(mock,
76+
"/api/datasources/uid/test-uid/resources",
77+
"/api/datasources/proxy/uid/test-uid",
78+
)
79+
80+
req, _ := http.NewRequest("POST", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/query", nil)
81+
resp, err := rt.RoundTrip(req)
82+
83+
require.NoError(t, err)
84+
assert.Equal(t, http.StatusOK, resp.StatusCode)
85+
assert.Len(t, mock.requests, 2, "should retry with fallback on 403")
86+
assert.Contains(t, mock.requests[1].URL.Path, "/api/datasources/proxy/uid/test-uid/api/v1/query")
87+
}
88+
89+
func TestDatasourceFallbackTransport_FallbackOn500(t *testing.T) {
90+
resetFallbackCache()
91+
92+
mock := &mockTransport{
93+
responses: map[string]*http.Response{
94+
"/api/datasources/uid/test-uid/resources": newMockResponse(http.StatusInternalServerError),
95+
"/api/datasources/proxy/uid/test-uid": newMockResponse(http.StatusOK),
96+
},
97+
}
98+
99+
rt := newDatasourceFallbackTransport(mock,
100+
"/api/datasources/uid/test-uid/resources",
101+
"/api/datasources/proxy/uid/test-uid",
102+
)
103+
104+
req, _ := http.NewRequest("POST", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/query", nil)
105+
resp, err := rt.RoundTrip(req)
106+
107+
require.NoError(t, err)
108+
assert.Equal(t, http.StatusOK, resp.StatusCode)
109+
assert.Len(t, mock.requests, 2, "should retry with fallback on 500")
110+
}
111+
112+
func TestDatasourceFallbackTransport_CachesFallback(t *testing.T) {
113+
resetFallbackCache()
114+
115+
mock := &mockTransport{
116+
responses: map[string]*http.Response{
117+
"/api/datasources/uid/test-uid/resources": newMockResponse(http.StatusForbidden),
118+
"/api/datasources/proxy/uid/test-uid": newMockResponse(http.StatusOK),
119+
},
120+
}
121+
122+
rt := newDatasourceFallbackTransport(mock,
123+
"/api/datasources/uid/test-uid/resources",
124+
"/api/datasources/proxy/uid/test-uid",
125+
)
126+
127+
// First request: discovers fallback is needed (2 round trips).
128+
req1, _ := http.NewRequest("GET", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/labels", nil)
129+
_, err := rt.RoundTrip(req1)
130+
require.NoError(t, err)
131+
assert.Len(t, mock.requests, 2)
132+
133+
// Second request: uses cached fallback directly (1 round trip).
134+
req2, _ := http.NewRequest("GET", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/query", nil)
135+
resp, err := rt.RoundTrip(req2)
136+
require.NoError(t, err)
137+
assert.Equal(t, http.StatusOK, resp.StatusCode)
138+
assert.Len(t, mock.requests, 3, "cached fallback should skip the primary attempt")
139+
assert.Contains(t, mock.requests[2].URL.Path, "/api/datasources/proxy/uid/test-uid/api/v1/query")
140+
}
141+
142+
func TestDatasourceFallbackTransport_BothFail(t *testing.T) {
143+
resetFallbackCache()
144+
145+
mock := &mockTransport{
146+
responses: map[string]*http.Response{
147+
"/api/datasources/uid/test-uid/resources": newMockResponse(http.StatusForbidden),
148+
"/api/datasources/proxy/uid/test-uid": newMockResponse(http.StatusForbidden),
149+
},
150+
}
151+
152+
rt := newDatasourceFallbackTransport(mock,
153+
"/api/datasources/uid/test-uid/resources",
154+
"/api/datasources/proxy/uid/test-uid",
155+
)
156+
157+
req, _ := http.NewRequest("GET", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/query", nil)
158+
resp, err := rt.RoundTrip(req)
159+
160+
require.NoError(t, err)
161+
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "should return fallback response when both fail")
162+
assert.Len(t, mock.requests, 2)
163+
}
164+
165+
func TestDatasourceFallbackTransport_PreservesPostBody(t *testing.T) {
166+
resetFallbackCache()
167+
168+
mock := &mockTransport{
169+
responses: map[string]*http.Response{
170+
"/api/datasources/uid/test-uid/resources": newMockResponse(http.StatusForbidden),
171+
"/api/datasources/proxy/uid/test-uid": newMockResponse(http.StatusOK),
172+
},
173+
}
174+
175+
rt := newDatasourceFallbackTransport(mock,
176+
"/api/datasources/uid/test-uid/resources",
177+
"/api/datasources/proxy/uid/test-uid",
178+
)
179+
180+
body := "query=up&time=1234567890"
181+
req, _ := http.NewRequest("POST", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/query",
182+
strings.NewReader(body))
183+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
184+
185+
resp, err := rt.RoundTrip(req)
186+
187+
require.NoError(t, err)
188+
assert.Equal(t, http.StatusOK, resp.StatusCode)
189+
require.Len(t, mock.requests, 2)
190+
191+
// Verify the retry request had the body.
192+
retryBody, err := io.ReadAll(mock.requests[1].Body)
193+
require.NoError(t, err)
194+
assert.Equal(t, body, string(retryBody))
195+
}
196+
197+
func TestDatasourceFallbackTransport_NoRetryOn4xx(t *testing.T) {
198+
resetFallbackCache()
199+
200+
mock := &mockTransport{
201+
responses: map[string]*http.Response{
202+
"/api/datasources/uid/test-uid/resources": newMockResponse(http.StatusBadRequest),
203+
},
204+
}
205+
206+
rt := newDatasourceFallbackTransport(mock,
207+
"/api/datasources/uid/test-uid/resources",
208+
"/api/datasources/proxy/uid/test-uid",
209+
)
210+
211+
req, _ := http.NewRequest("GET", "http://grafana.example.com/api/datasources/uid/test-uid/resources/api/v1/query", nil)
212+
resp, err := rt.RoundTrip(req)
213+
214+
require.NoError(t, err)
215+
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
216+
assert.Len(t, mock.requests, 1, "should not retry on non-403/500 errors")
217+
}
218+
219+
func TestDatasourceProxyPaths(t *testing.T) {
220+
resources, proxy := datasourceProxyPaths("my-uid-123")
221+
assert.Equal(t, "/api/datasources/uid/my-uid-123/resources", resources)
222+
assert.Equal(t, "/api/datasources/proxy/uid/my-uid-123", proxy)
223+
}

tools/loki.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ func newLokiClient(ctx context.Context, uid string) (*Client, error) {
6767
}
6868

6969
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
70-
url := fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid)
70+
grafanaURL := strings.TrimRight(cfg.URL, "/")
71+
resourcesBase, proxyBase := datasourceProxyPaths(uid)
72+
url := grafanaURL + proxyBase
7173

7274
// Create custom transport with TLS configuration if available
7375
transport, err := mcpgrafana.BuildTransport(&cfg, nil)
@@ -77,10 +79,13 @@ func newLokiClient(ctx context.Context, uid string) (*Client, error) {
7779
transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
7880
transport = mcpgrafana.NewOrgIDRoundTripper(transport, cfg.OrgID)
7981

82+
// Wrap with fallback transport: try /proxy first, fall back to /resources
83+
// on 403/500 for compatibility with different managed Grafana deployments.
84+
var rt http.RoundTripper = mcpgrafana.NewUserAgentTransport(transport)
85+
rt = newDatasourceFallbackTransport(rt, proxyBase, resourcesBase)
86+
8087
client := &http.Client{
81-
Transport: mcpgrafana.NewUserAgentTransport(
82-
transport,
83-
),
88+
Transport: rt,
8489
}
8590

8691
return &Client{

tools/prom_backend.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ type prometheusBackend struct {
5757

5858
func newPrometheusBackend(ctx context.Context, uid string, ds *models.DataSource) (*prometheusBackend, error) {
5959
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
60-
url := fmt.Sprintf("%s/api/datasources/uid/%s/resources", trimTrailingSlash(cfg.URL), uid)
60+
grafanaURL := trimTrailingSlash(cfg.URL)
61+
resourcesBase, proxyBase := datasourceProxyPaths(uid)
62+
url := grafanaURL + resourcesBase
6163

6264
rt, err := mcpgrafana.BuildTransport(&cfg, api.DefaultRoundTripper)
6365
if err != nil {
@@ -78,6 +80,10 @@ func newPrometheusBackend(ctx context.Context, uid string, ds *models.DataSource
7880
}
7981
}
8082

83+
// Wrap with fallback transport: try /resources first, fall back to /proxy
84+
// on 403/500 for compatibility with different managed Grafana deployments.
85+
rt = newDatasourceFallbackTransport(rt, resourcesBase, proxyBase)
86+
8187
c, err := api.NewClient(api.Config{
8288
Address: url,
8389
RoundTripper: rt,

0 commit comments

Comments
 (0)