Skip to content

Commit

Permalink
feat: create new Metabase client (#3970)
Browse files Browse the repository at this point in the history
Co-authored-by: Dafydd Llŷr Pearson <[email protected]>
  • Loading branch information
zz-hh-aa and DafyddLlyr authored Dec 2, 2024
1 parent 5392673 commit d4bbe91
Show file tree
Hide file tree
Showing 2 changed files with 244 additions and 1 deletion.
116 changes: 115 additions & 1 deletion api.planx.uk/modules/analytics/metabase/shared/client.test.ts
Original file line number Diff line number Diff line change
@@ -1 +1,115 @@
test.todo("should test configuration and errors");
import axios from "axios";
import {
validateConfig,
createMetabaseClient,
MetabaseError,
} from "./client.js";
import nock from "nock";

const axiosCreateSpy = vi.spyOn(axios, "create");

describe("Metabase client", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});

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

test("returns configured client", async () => {
const client = createMetabaseClient();
expect(client.defaults.baseURL).toBe(process.env.METABASE_URL_EXT);
expect(client.defaults.headers["X-API-Key"]).toBe(
process.env.METABASE_API_KEY,
);
expect(client.defaults.headers["Content-Type"]).toBe("application/json");
expect(client.defaults.timeout).toBe(30_000);
});

describe("validates configuration", () => {
test("throws error when URL_EXT is missing", () => {
vi.stubEnv("METABASE_URL_EXT", undefined);
expect(() => validateConfig()).toThrow(
"Missing environment variable 'METABASE_URL_EXT'",
);
});

test("throws error when API_KEY is missing", () => {
vi.stubEnv("METABASE_API_KEY", undefined);
expect(() => validateConfig()).toThrow(
"Missing environment variable 'METABASE_API_KEY'",
);
});

test("returns valid config object", () => {
const config = validateConfig();
expect(config).toMatchObject({
baseURL: process.env.METABASE_URL_EXT,
apiKey: process.env.METABASE_API_KEY,
timeout: 30_000,
retries: 3,
});
});
});

describe("Error handling", () => {
test("retries then succeeds on 5xx errors", async () => {
const metabaseScope = nock(process.env.METABASE_URL_EXT!);

metabaseScope
.get("/test")
.reply(500, { message: "Internal Server Error" })
.get("/test")
.reply(200, { data: "success" });

const client = createMetabaseClient();
const response = await client.get("/test");

expect(response.data).toEqual({ data: "success" });
expect(metabaseScope.isDone()).toBe(true);
});

test("throws an error if all requests fail", async () => {
const metabaseScope = nock(process.env.METABASE_URL_EXT!);

metabaseScope
.get("/test")
.times(4)
.reply(500, { message: "Internal Server Error" });

const client = createMetabaseClient();

try {
await client.get("/test");
expect.fail("Should have thrown an error");
} catch (error) {
expect(error).toBeInstanceOf(MetabaseError);
expect((error as MetabaseError).statusCode).toBe(500);
expect(metabaseScope.isDone()).toBe(true);
}
});

test("does not retry on non-5xx errors", async () => {
const metabaseScope = nock(process.env.METABASE_URL_EXT!);

metabaseScope.get("/test").once().reply(200, { data: "success" });

const client = createMetabaseClient();
const response = await client.get("/test");

expect(response.data).toEqual({ data: "success" });

// All expected requests were made
expect(metabaseScope.isDone()).toBe(true);

// No pending mocks left
expect(metabaseScope.pendingMocks()).toHaveLength(0);

// Double check that no other requests were intercepted
const requestCount = metabaseScope.activeMocks().length;
expect(requestCount).toBe(0);
});
});
});
129 changes: 129 additions & 0 deletions api.planx.uk/modules/analytics/metabase/shared/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import axios from "axios";
import type {
AxiosInstance,
AxiosError,
InternalAxiosRequestConfig,
} from "axios";

// Custom error class for Metabase-specific errors
export class MetabaseError extends Error {
constructor(
message: string,
public statusCode?: number,
public response?: unknown,
) {
super(message);
this.name = "MetabaseError";
}
}

interface MetabaseConfig {
baseURL: string;
apiKey: string;
timeout?: number;
retries?: number;
}

// Validate environment variables
export const validateConfig = (): MetabaseConfig => {
const baseURL = process.env.METABASE_URL_EXT;
const apiKey = process.env.METABASE_API_KEY;

const METABASE_TIMEOUT = 30_000;
const METABASE_MAX_RETRIES = 3;

assert(baseURL, "Missing environment variable 'METABASE_URL_EXT'");
assert(apiKey, "Missing environment variable 'METABASE_API_KEY'");

return {
baseURL,
apiKey,
timeout: METABASE_TIMEOUT,
retries: METABASE_MAX_RETRIES,
};
};

// Extended request config to include retry count
interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig {
retryCount?: number;
}

// Create and configure Axios instance
export const createMetabaseClient = (): AxiosInstance => {
const config = validateConfig();

const client = axios.create({
baseURL: config.baseURL,
headers: {
"X-API-Key": config.apiKey,
"Content-Type": "application/json",
},
timeout: config.timeout,
});

client.interceptors.response.use(
(response) => {
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as ExtendedAxiosRequestConfig;

if (!originalRequest) {
throw new MetabaseError("No request config available");
}

// Initialise retry count if not present
if (typeof originalRequest.retryCount === "undefined") {
originalRequest.retryCount = 0;
}

// Handle retry logic
if (error.response) {
// Retry on 5xx errors
if (
error.response.status >= 500 &&
originalRequest.retryCount < (config.retries ?? 3)
) {
originalRequest.retryCount++;
return client.request(originalRequest);
}

// Transform error response
const errorMessage =
typeof error.response.data === "object" &&
error.response.data !== null &&
"message" in error.response.data
? String(error.response.data.message)
: "Metabase request failed";

throw new MetabaseError(
errorMessage,
error.response.status,
error.response.data,
);
}

// Handle network errors
if (error.request) {
throw new MetabaseError(
"Network error occurred",
undefined,
error.request,
);
}

// Handle other errors
throw new MetabaseError(error.message);
},
);

return client;
};

// // Export both client and instance with delayed instantiation for test purposes
// export let metabaseClient: AxiosInstance;

// export const initializeMetabaseClient = () => {
// metabaseClient = createMetabaseClient();
// return metabaseClient;
// };

0 comments on commit d4bbe91

Please sign in to comment.