Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ turning expected caller failures into exception issues:
| Attribute | Meaning | Example values |
| --- | --- | --- |
| `mcp.tool.name` | Fixed registered tool name | `get_timeseries`, `compare_periods` |
| `error.kind` | Bounded failure category | `user_input`, `plausible_api`, `unexpected` |
| `error.kind` | Bounded failure category | `user_input`, `plausible_api`, `plausible_unreachable`, `unexpected` |
| `http.response.status_code` | Plausible status, when applicable | `400`, `401`, `429`, `503` |

Unexpected failures and Plausible 5xx responses are also captured as exceptions. User input
Expand Down
159 changes: 145 additions & 14 deletions __tests__/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ vi.mock("@sentry/cloudflare", () => ({

const { captureException, count } = sentry;

import { PlausibleApiError } from "../src/plausible.js";
import { reportToolError, UserFacingError } from "../src/errors.js";
import { PlausibleApiError, PlausibleUnreachableError } from "../src/plausible.js";
import { reportToolError, toolErrorResult, UserFacingError } from "../src/errors.js";

const toolName = "get_timeseries";

Expand All @@ -26,7 +26,11 @@ describe("reportToolError", () => {
it("records user-facing failures without creating an exception issue", () => {
const error = new UserFacingError("site_id is required");

expect(reportToolError(error, toolName)).toBe("site_id is required");
expect(reportToolError(error, toolName)).toEqual({
code: "invalid_input",
message: "site_id is required",
retryable: false,
});
expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, {
attributes: {
"mcp.tool.name": toolName,
Expand All @@ -39,9 +43,11 @@ describe("reportToolError", () => {
it("records expected Plausible 4xx responses without creating an exception issue", () => {
const error = new PlausibleApiError(401, "Invalid API key or site ID");

expect(reportToolError(error, toolName)).toBe(
"Plausible rejected the API key or site_id (401)",
);
expect(reportToolError(error, toolName)).toEqual({
code: "unauthorized",
message: "Plausible rejected the API key or site_id (401)",
retryable: false,
});
expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, {
attributes: {
"mcp.tool.name": toolName,
Expand All @@ -55,7 +61,11 @@ describe("reportToolError", () => {
it("captures Plausible 5xx responses while recording the tool failure", () => {
const error = new PlausibleApiError(503, "Unavailable");

expect(reportToolError(error, toolName)).toBe("Plausible API returned 503");
expect(reportToolError(error, toolName)).toEqual({
code: "upstream_unavailable",
message: "Plausible API returned 503",
retryable: true,
});
expect(count).toHaveBeenCalledOnce();
expect(captureException).toHaveBeenCalledWith(error);
});
Expand All @@ -66,24 +76,26 @@ describe("reportToolError", () => {
JSON.stringify({ error: "Invalid metric \"pagevies\"" })
);

expect(reportToolError(error, toolName)).toBe(
'Plausible API returned 400: Invalid metric "pagevies"',
);
expect(reportToolError(error, toolName)).toEqual({
code: "invalid_query",
message: 'Plausible API returned 400: Invalid metric "pagevies"',
retryable: false,
});
expect(captureException).not.toHaveBeenCalled();
});

it("appends the Plausible error message to the 401 guidance", () => {
const error = new PlausibleApiError(401, JSON.stringify({ error: "site does not exist" }));

expect(reportToolError(error, toolName)).toBe(
expect(reportToolError(error, toolName).message).toBe(
"Plausible rejected the API key or site_id (401): site does not exist",
);
});

it("falls back to the status-only message for a non-JSON body", () => {
const error = new PlausibleApiError(502, "<html>Bad Gateway</html>");

expect(reportToolError(error, toolName)).toBe("Plausible API returned 502");
expect(reportToolError(error, toolName).message).toBe("Plausible API returned 502");
});

it("gives the caller a 5xx detail while the captured exception stays status-only", () => {
Expand All @@ -92,7 +104,7 @@ describe("reportToolError", () => {
JSON.stringify({ error: "failed filter customer@example.com" })
);

expect(reportToolError(error, toolName)).toBe(
expect(reportToolError(error, toolName).message).toBe(
"Plausible API returned 503: failed filter customer@example.com",
);
expect(captureException).toHaveBeenCalledWith(error);
Expand All @@ -101,10 +113,31 @@ describe("reportToolError", () => {
expect(JSON.stringify(captured)).not.toContain("customer@example.com");
});

it("classifies an unreachable Plausible API as retryable upstream_unavailable", () => {
const error = new PlausibleUnreachableError(new TypeError("fetch failed"));

expect(reportToolError(error, toolName)).toEqual({
code: "upstream_unavailable",
message: "Could not reach the Plausible API",
retryable: true,
});
expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, {
attributes: {
"mcp.tool.name": toolName,
"error.kind": "plausible_unreachable",
},
});
expect(captureException).toHaveBeenCalledWith(error);
});

it("captures unexpected failures and returns a safe message", () => {
const error = new Error("secret implementation detail");

expect(reportToolError(error, toolName)).toBe("An unexpected error occurred");
expect(reportToolError(error, toolName)).toEqual({
code: "internal",
message: "An unexpected error occurred",
retryable: false,
});
expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, {
attributes: {
"mcp.tool.name": toolName,
Expand All @@ -113,4 +146,102 @@ describe("reportToolError", () => {
});
expect(captureException).toHaveBeenCalledWith(error);
});

it("classifies 403 as unauthorized", () => {
const error = new PlausibleApiError(403, JSON.stringify({ error: "forbidden" }));

expect(reportToolError(error, toolName)).toEqual({
code: "unauthorized",
message: "Plausible API returned 403: forbidden",
retryable: false,
});
expect(captureException).not.toHaveBeenCalled();
});

it("classifies 429 as retryable and carries the wait time when known", () => {
const error = new PlausibleApiError(429, "", 30);

expect(reportToolError(error, toolName)).toEqual({
code: "rate_limited",
message: "Plausible rate limit exceeded (429)",
retryable: true,
retry_after_seconds: 30,
});
expect(captureException).not.toHaveBeenCalled();
});

it("carries the wait time on a 5xx upstream_unavailable", () => {
expect(reportToolError(new PlausibleApiError(503, "", 7), toolName)).toMatchObject({
code: "upstream_unavailable",
retryable: true,
retry_after_seconds: 7,
});
});

it("omits retry_after_seconds on 429 without a Retry-After header", () => {
const info = reportToolError(new PlausibleApiError(429, ""), toolName);

expect(info.code).toBe("rate_limited");
expect(info.retryable).toBe(true);
expect(info).not.toHaveProperty("retry_after_seconds");
});
});

describe("toolErrorResult", () => {
beforeEach(() => {
captureException.mockReset();
count.mockReset();
});

it("returns an isError result with the error as JSON in a second text block", () => {
const result = toolErrorResult(new UserFacingError("site_id is required"), toolName);

expect(result.isError).toBe(true);
expect(result.content).toHaveLength(2);
expect(result.content[0]).toEqual({ type: "text", text: "Error: site_id is required" });
expect(JSON.parse(result.content[1].text)).toEqual({
error: { code: "invalid_input", message: "site_id is required", retryable: false },
});
});

it("carries no structuredContent", () => {
const result = toolErrorResult(new UserFacingError("site_id is required"), toolName);

expect(result).not.toHaveProperty("structuredContent");
});

it("appends the wait time for a rate limit", () => {
const result = toolErrorResult(new PlausibleApiError(429, "", 12), toolName);

expect(result.content[0].text).toBe(
"Error: Plausible rate limit exceeded (429) Retry after 12 seconds.",
);
expect(JSON.parse(result.content[1].text).error.retry_after_seconds).toBe(12);
});

it("puts the wait time of a 503 in the JSON error block", () => {
const result = toolErrorResult(new PlausibleApiError(503, "", 9), toolName);

expect(result.content[0].text).toBe(
"Error: Plausible API returned 503 Retry after 9 seconds.",
);
expect(JSON.parse(result.content[1].text).error).toMatchObject({
code: "upstream_unavailable",
retry_after_seconds: 9,
});
});

it("flags a retryable error without a wait time as temporary", () => {
const result = toolErrorResult(new PlausibleApiError(503, ""), toolName);

expect(result.content[0].text).toBe(
"Error: Plausible API returned 503 This error is temporary; retry later.",
);
});

it("adds no retry hint to a non-retryable error", () => {
const result = toolErrorResult(new PlausibleApiError(400, ""), toolName);

expect(result.content[0].text).toBe("Error: Plausible API returned 400");
});
});
101 changes: 99 additions & 2 deletions __tests__/plausible.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
PlausibleClient,
PlausibleApiError,
parsePlausibleErrorDetail,
PlausibleUnreachableError,
} from "../src/plausible.js";

const mockFetch = vi.fn();
Expand All @@ -15,10 +16,11 @@ function mockOk(data: unknown) {
});
}

function mockError(status: number, body: string) {
function mockError(status: number, body: string, headers: Record<string, string> = {}) {
mockFetch.mockResolvedValueOnce({
ok: false,
status,
headers: new Headers(headers),
text: () => Promise.resolve(body),
});
}
Expand Down Expand Up @@ -75,6 +77,18 @@ describe("PlausibleClient", () => {
expect(body.date_range).toEqual(["2026-07-01", "2026-07-07"]);
});

it("wraps a rejected fetch as PlausibleUnreachableError", async () => {
const cause = new TypeError("fetch failed");
mockFetch.mockRejectedValueOnce(cause);

const error = await client
.query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" })
.catch((e) => e);

expect(error).toBeInstanceOf(PlausibleUnreachableError);
expect((error as Error).cause).toBe(cause);
});

it("includes dimensions when provided", async () => {
mockOk({ results: [], meta: {}, query: {} });

Expand Down Expand Up @@ -137,6 +151,32 @@ describe("PlausibleClient", () => {
expect(body.pagination).toEqual({ limit: 10, offset: 5 });
});

it("includes order_by when provided and omits it when empty", async () => {
mockOk({ results: [], meta: {}, query: {} });
await client.query({
site_id: "example.com",
metrics: ["visitors"],
date_range: "7d",
order_by: [
["visitors", "desc"],
["event:page", "asc"],
],
});
expect(JSON.parse(mockFetch.mock.calls[0][1].body).order_by).toEqual([
["visitors", "desc"],
["event:page", "asc"],
]);

mockOk({ results: [], meta: {}, query: {} });
await client.query({
site_id: "example.com",
metrics: ["visitors"],
date_range: "7d",
order_by: [],
});
expect(JSON.parse(mockFetch.mock.calls[1][1].body)).not.toHaveProperty("order_by");
});

it("uses self-hosted base URL", async () => {
const selfHosted = new PlausibleClient({
apiKey: "key",
Expand Down Expand Up @@ -230,6 +270,63 @@ describe("PlausibleClient", () => {
).rejects.toThrow(PlausibleApiError);
});

it("exposes an integer Retry-After header as retryAfterSeconds", async () => {
mockError(429, "Rate limit exceeded", { "Retry-After": "30" });

const error = await client
.query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" })
.catch((e) => e);

expect(error).toBeInstanceOf(PlausibleApiError);
expect((error as PlausibleApiError).retryAfterSeconds).toBe(30);
});

describe("HTTP-date Retry-After", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-21T07:00:00Z"));
});

afterEach(() => {
vi.useRealTimers();
});

async function retryAfterFor(header: string) {
mockError(429, "Rate limit exceeded", { "Retry-After": header });
const error = await client
.query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" })
.catch((e) => e);
return (error as PlausibleApiError).retryAfterSeconds;
}

it("converts a future date to seconds from now", async () => {
expect(await retryAfterFor("Wed, 21 Oct 2026 07:00:30 GMT")).toBe(30);
});

it("rounds a fractional wait up", async () => {
vi.setSystemTime(new Date("2026-10-21T07:00:00.500Z"));
expect(await retryAfterFor("Wed, 21 Oct 2026 07:00:30 GMT")).toBe(30);
});

it("clamps a past date to 0", async () => {
expect(await retryAfterFor("Wed, 21 Oct 2026 06:00:00 GMT")).toBe(0);
});

it("ignores an unparseable value", async () => {
expect(await retryAfterFor("soon")).toBeUndefined();
});
});

it("leaves retryAfterSeconds undefined without a Retry-After header", async () => {
mockError(429, "Rate limit exceeded");

const error = await client
.query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" })
.catch((e) => e);

expect((error as PlausibleApiError).retryAfterSeconds).toBeUndefined();
});

it("throws PlausibleApiError on 500", async () => {
mockError(500, "Internal Server Error");

Expand Down
1 change: 1 addition & 0 deletions __tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ describe("MCP Server Integration", () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
headers: new Headers(),
text: () => Promise.resolve("Invalid API key"),
});

Expand Down
Loading
Loading