Skip to content

Commit 683faed

Browse files
committed
fix(grafana): retry failed API version discovery
1 parent 47aedb2 commit 683faed

2 files changed

Lines changed: 112 additions & 8 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
interface GrafanaFetchMock {
4+
fetcher: typeof fetch;
5+
urls: string[];
6+
}
7+
8+
function createGrafanaFetchMock(handler: (url: URL) => Response): GrafanaFetchMock {
9+
const urls: string[] = [];
10+
const fetcher = (async (input: RequestInfo | URL): Promise<Response> => {
11+
const url = new URL(input instanceof Request ? input.url : String(input));
12+
urls.push(url.toString());
13+
return handler(url);
14+
}) as typeof fetch;
15+
return { fetcher, urls };
16+
}
17+
18+
function jsonResponse(value: unknown, status = 200): Response {
19+
return new Response(JSON.stringify(value), {
20+
status,
21+
headers: { "content-type": "application/json" },
22+
});
23+
}
24+
25+
describe("Grafana App Platform API version discovery", () => {
26+
it("prefers v1 and caches a successfully discovered version", async () => {
27+
vi.resetModules();
28+
const { grafanaActionHandlers } = await import("./runtime.ts");
29+
let discoveryRequests = 0;
30+
const mock = createGrafanaFetchMock((url) => {
31+
if (url.pathname === "/apis/folder.grafana.app") {
32+
discoveryRequests += 1;
33+
return jsonResponse({
34+
versions: [{ version: "v0alpha1" }, { version: "v1beta1" }, { version: "v1" }],
35+
});
36+
}
37+
return jsonResponse({ items: [], metadata: {} });
38+
});
39+
const context = {
40+
baseUrl: "https://grafana-v1.example",
41+
apiKey: "test-token",
42+
fetcher: mock.fetcher,
43+
};
44+
45+
await grafanaActionHandlers.list_folders({}, context);
46+
await grafanaActionHandlers.list_folders({}, context);
47+
48+
expect(discoveryRequests).toBe(1);
49+
expect(mock.urls).toEqual([
50+
"https://grafana-v1.example/apis/folder.grafana.app",
51+
"https://grafana-v1.example/apis/folder.grafana.app/v1/namespaces/default/folders",
52+
"https://grafana-v1.example/apis/folder.grafana.app/v1/namespaces/default/folders",
53+
]);
54+
});
55+
56+
it("retries discovery after a failure instead of caching the v1 fallback", async () => {
57+
vi.resetModules();
58+
const { grafanaActionHandlers } = await import("./runtime.ts");
59+
let discoveryRequests = 0;
60+
const mock = createGrafanaFetchMock((url) => {
61+
if (url.pathname === "/apis/folder.grafana.app") {
62+
discoveryRequests += 1;
63+
if (discoveryRequests === 1) {
64+
return jsonResponse({ message: "temporary failure" }, 500);
65+
}
66+
return jsonResponse({ versions: [{ version: "v1beta1" }] });
67+
}
68+
if (url.pathname.includes("/v1beta1/")) {
69+
return jsonResponse({ items: [], metadata: {} });
70+
}
71+
return jsonResponse({ message: "unsupported version" }, 404);
72+
});
73+
const context = {
74+
baseUrl: "https://grafana-v1beta1.example",
75+
apiKey: "test-token",
76+
fetcher: mock.fetcher,
77+
};
78+
79+
await expect(grafanaActionHandlers.list_folders({}, context)).rejects.toThrow("unsupported version");
80+
await expect(grafanaActionHandlers.list_folders({}, context)).resolves.toMatchObject({ folders: [] });
81+
82+
expect(discoveryRequests).toBe(2);
83+
expect(mock.urls).toEqual([
84+
"https://grafana-v1beta1.example/apis/folder.grafana.app",
85+
"https://grafana-v1beta1.example/apis/folder.grafana.app/v1/namespaces/default/folders",
86+
"https://grafana-v1beta1.example/apis/folder.grafana.app",
87+
"https://grafana-v1beta1.example/apis/folder.grafana.app/v1beta1/namespaces/default/folders",
88+
]);
89+
});
90+
});

src/providers/grafana/runtime.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ import {
1313
const defaultNamespace = "default";
1414
const grafanaDefaultRequestTimeoutMs = 30_000;
1515
const folderParentAnnotation = "grafana.app/folder";
16+
const grafanaDefaultApiVersion = "v1";
17+
const grafanaApiVersionCacheMaxEntries = 256;
1618

17-
const grafanaAppApiGroups = {
19+
type GrafanaAppResource = "folders" | "dashboards";
20+
21+
const grafanaAppApiGroups: Record<GrafanaAppResource, string> = {
1822
folders: "folder.grafana.app",
1923
dashboards: "dashboard.grafana.app",
20-
} as const;
24+
};
2125

2226
// Grafana's App Platform API groups are versioned and the set of served versions
2327
// differs per Grafana release, e.g.
@@ -28,7 +32,7 @@ const grafanaAppApiGroups = {
2832
// ("the server could not find the requested resource"), so the version has to be
2933
// discovered instead of hardcoded. Only versions from the v1 lineage are listed:
3034
// the v2 lineage uses a different resource schema and is not interchangeable here.
31-
const grafanaApiVersionPreference = ["v1", "v1beta1", "v0alpha1"] as const;
35+
const grafanaApiVersionPreference: readonly string[] = [grafanaDefaultApiVersion, "v1beta1", "v0alpha1"];
3236

3337
const grafanaApiVersionCache = new Map<string, string>();
3438
const grafanaApiMetadataUrl = "https://grafana.com/docs/grafana/latest/developers/http_api/auth/#service-account-token";
@@ -455,7 +459,6 @@ async function resolveGrafanaApiVersion(
455459
return cached;
456460
}
457461

458-
let resolved: string = grafanaApiVersionPreference[0];
459462
try {
460463
const payload = await grafanaRequestJson(`/apis/${group}`, { method: "GET" }, context);
461464
const record = optionalRecord(payload) ?? {};
@@ -466,20 +469,31 @@ async function resolveGrafanaApiVersion(
466469
);
467470
const match = grafanaApiVersionPreference.find((version) => served.has(version));
468471
if (match !== undefined) {
469-
resolved = match;
472+
cacheGrafanaApiVersion(cacheKey, match);
473+
return match;
470474
}
471475
} catch {
472476
// Discovery is best-effort. Falling back to the newest known version keeps the
473477
// previous behaviour for servers that do not expose the discovery endpoint.
474478
}
475479

476-
grafanaApiVersionCache.set(cacheKey, resolved);
477-
return resolved;
480+
return grafanaDefaultApiVersion;
481+
}
482+
483+
function cacheGrafanaApiVersion(cacheKey: string, version: string): void {
484+
grafanaApiVersionCache.delete(cacheKey);
485+
if (grafanaApiVersionCache.size >= grafanaApiVersionCacheMaxEntries) {
486+
const oldestKey = grafanaApiVersionCache.keys().next().value;
487+
if (oldestKey !== undefined) {
488+
grafanaApiVersionCache.delete(oldestKey);
489+
}
490+
}
491+
grafanaApiVersionCache.set(cacheKey, version);
478492
}
479493

480494
async function apiPath(
481495
input: Record<string, unknown>,
482-
resource: "folders" | "dashboards",
496+
resource: GrafanaAppResource,
483497
context: GrafanaContext & { phase: GrafanaRequestPhase },
484498
): Promise<string> {
485499
const namespace = optionalString(input.namespace) ?? defaultNamespace;

0 commit comments

Comments
 (0)