feat: add shared API client with auth and error handling

This commit is contained in:
2026-03-30 13:53:02 +02:00
parent 033a29077e
commit 0fa7a41457
+113
View File
@@ -0,0 +1,113 @@
import axios, { type AxiosRequestConfig } from "axios";
import { getApiBaseUrl, getApiKey, CHARACTER_LIMIT } from "../constants.js";
/**
* Make an authenticated request to the Novu API.
*
* @param path - API path including version prefix (e.g., "/v1/events/trigger" or "/v2/topics")
* @param method - HTTP method
* @param data - Request body for POST/PUT/PATCH
* @param params - Query parameters for GET requests
*/
export async function novuRequest<T>(
path: string,
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" = "GET",
data?: unknown,
params?: Record<string, unknown>
): Promise<T> {
// Strip undefined values from params to avoid sending "?key=undefined"
const cleanParams = params
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== undefined)
)
: undefined;
const config: AxiosRequestConfig = {
method,
url: `${getApiBaseUrl()}${path}`,
data,
params: cleanParams,
timeout: 30_000,
headers: {
Authorization: `ApiKey ${getApiKey()}`,
"Content-Type": "application/json",
Accept: "application/json",
},
};
const response = await axios(config);
return response.data as T;
}
/**
* Format an API error into an actionable string for the LLM.
*/
export function formatApiError(error: unknown): string {
if (axios.isAxiosError(error)) {
if (error.response) {
const status = error.response.status;
const body = error.response.data as
| { message?: string; error?: string }
| undefined;
const detail =
body?.message || body?.error || JSON.stringify(body);
switch (status) {
case 400:
return `Error (400 Bad Request): ${detail}. Check that all required parameters are provided and valid.`;
case 401:
return "Error (401 Unauthorized): Invalid or missing API key. Set NOVU_SECRET_KEY environment variable with a valid key from https://dashboard.novu.co";
case 403:
return `Error (403 Forbidden): ${detail}. Check your API key permissions.`;
case 404:
return `Error (404 Not Found): ${detail}. Verify the resource ID/key is correct.`;
case 409:
return `Error (409 Conflict): ${detail}. The resource may already exist.`;
case 413:
return `Error (413 Payload Too Large): ${detail}. Reduce the request body size.`;
case 422:
return `Error (422 Unprocessable Entity): ${detail}. Check request body schema.`;
case 429:
return "Error (429 Rate Limited): Too many requests. Wait before retrying. See https://docs.novu.co/api-reference/rate-limiting";
default:
return `Error (${status}): ${detail}`;
}
} else if (error.code === "ECONNABORTED") {
return "Error: Request timed out after 30s. Try again or check Novu service status.";
} else if (error.code === "ENOTFOUND") {
return `Error: Cannot reach Novu API at ${getApiBaseUrl()}. Check NOVU_API_URL and network connectivity.`;
}
}
return `Error: ${error instanceof Error ? error.message : String(error)}`;
}
/**
* Truncate a string if it exceeds CHARACTER_LIMIT, appending a truncation notice.
*/
export function truncateIfNeeded(text: string): string {
if (text.length <= CHARACTER_LIMIT) return text;
const truncated = text.slice(0, CHARACTER_LIMIT);
return (
truncated +
"\n\n... [Response truncated. Use pagination or filters to see more results.]"
);
}
/**
* Create a standard MCP tool result (success).
*/
export function toolResult(text: string) {
return {
content: [{ type: "text" as const, text: truncateIfNeeded(text) }],
};
}
/**
* Create a standard MCP tool result (error).
*/
export function toolError(error: unknown) {
return {
content: [{ type: "text" as const, text: formatApiError(error) }],
isError: true,
};
}