diff --git a/packages/core/src/api/api-result.ts b/packages/core/src/api/api-result.ts index 6808d1673bad0..1c12e550d5bd4 100644 --- a/packages/core/src/api/api-result.ts +++ b/packages/core/src/api/api-result.ts @@ -1,5 +1,8 @@ +import type { ErrorDetails } from "../common/error.js"; + /** What every api handler returns: a rendered card, or a rendered error. */ export interface ApiResult { status: "success" | "error - permanent" | "error - temporary"; + error?: ErrorDetails; content: string; } diff --git a/packages/core/src/api/gist.ts b/packages/core/src/api/gist.ts index 75547d9ea08ea..0a4f05e9e1134 100644 --- a/packages/core/src/api/gist.ts +++ b/packages/core/src/api/gist.ts @@ -3,6 +3,7 @@ import type { ColorParams } from "../common/color.js"; import { findInvalidColorParam, pickColorParams } from "../common/color.js"; import { MissingParamError, + describeError, retrieveSecondaryMessage, } from "../common/error.js"; import { parseBoolean } from "../common/ops.js"; @@ -103,6 +104,7 @@ export default async ( if (err instanceof Error) { return { status: "error - temporary", + error: describeError(err), content: renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), diff --git a/packages/core/src/api/index.js b/packages/core/src/api/index.js index b7570b3280845..95a99eb0fdf50 100644 --- a/packages/core/src/api/index.js +++ b/packages/core/src/api/index.js @@ -2,6 +2,7 @@ import { renderStatsCard } from "../cards/stats.js"; import { findInvalidColorParam, pickColorParams } from "../common/color.js"; import { MissingParamError, + describeError, retrieveSecondaryMessage, } from "../common/error.js"; import { parseArray, parseBoolean } from "../common/ops.js"; @@ -160,6 +161,7 @@ export default async ( if (err instanceof Error) { return { status: "error - temporary", + error: describeError(err), content: renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), diff --git a/packages/core/src/api/pin.js b/packages/core/src/api/pin.js index 7d5688459369d..b584ccf26c11d 100644 --- a/packages/core/src/api/pin.js +++ b/packages/core/src/api/pin.js @@ -2,6 +2,7 @@ import { renderRepoCard } from "../cards/repo.js"; import { findInvalidColorParam, pickColorParams } from "../common/color.js"; import { MissingParamError, + describeError, retrieveSecondaryMessage, } from "../common/error.js"; import { parseArray, parseBoolean } from "../common/ops.js"; @@ -105,6 +106,7 @@ export default async ( if (err instanceof Error) { return { status: "error - temporary", + error: describeError(err), content: renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), diff --git a/packages/core/src/api/top-langs.js b/packages/core/src/api/top-langs.js index db3ba3141917c..74076bca2c4c2 100644 --- a/packages/core/src/api/top-langs.js +++ b/packages/core/src/api/top-langs.js @@ -2,6 +2,7 @@ import { renderTopLanguages } from "../cards/top-languages.js"; import { findInvalidColorParam, pickColorParams } from "../common/color.js"; import { MissingParamError, + describeError, retrieveSecondaryMessage, } from "../common/error.js"; import { parseArray, parseBoolean } from "../common/ops.js"; @@ -132,6 +133,7 @@ export default async ( if (err instanceof Error) { return { status: "error - temporary", + error: describeError(err), content: renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), diff --git a/packages/core/src/api/wakatime.js b/packages/core/src/api/wakatime.js index 56b2b457ab7e2..d2f143483d598 100644 --- a/packages/core/src/api/wakatime.js +++ b/packages/core/src/api/wakatime.js @@ -2,6 +2,7 @@ import { renderWakatimeCard } from "../cards/wakatime.js"; import { findInvalidColorParam, pickColorParams } from "../common/color.js"; import { MissingParamError, + describeError, retrieveSecondaryMessage, } from "../common/error.js"; import { parseArray, parseBoolean } from "../common/ops.js"; @@ -90,6 +91,7 @@ export default async ({ if (err instanceof Error) { return { status: "error - temporary", + error: describeError(err), content: renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), diff --git a/packages/core/src/common/error.ts b/packages/core/src/common/error.ts index 0cb3b0f8c5877..3c751586332e6 100644 --- a/packages/core/src/common/error.ts +++ b/packages/core/src/common/error.ts @@ -87,10 +87,45 @@ const retrieveSecondaryMessage = (err: Error): string | undefined => { : undefined; }; +/** + * Structured details of a caught error for API results. + */ +export interface ErrorDetails { + /** Error type such as `MAX_RETRY`. Absent when the error has no type. */ + type?: string; + message: string; + secondaryMessage?: string; +} + +/** + * Extract structured details from a caught error. + * + * Callers attach the result to API results as an optional `error` field. + * The `status` value itself stays stable, so exact comparisons in + * `apps/backend/router.js` and external callers keep working. + * + * @param err The caught error. + * @returns The available error details. + */ +const describeError = (err: Error): ErrorDetails => { + const details: ErrorDetails = { message: err.message }; + const secondaryMessage = retrieveSecondaryMessage(err); + + if (err instanceof CustomError) { + details.type = err.type; + } + if (secondaryMessage !== undefined) { + details.secondaryMessage = secondaryMessage; + } + + return details; +}; + export { CustomError, MissingParamError, SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER, + describeError, retrieveSecondaryMessage, }; diff --git a/packages/core/tests/describeError.test.ts b/packages/core/tests/describeError.test.ts new file mode 100644 index 0000000000000..3d9b82a8a9eb6 --- /dev/null +++ b/packages/core/tests/describeError.test.ts @@ -0,0 +1,107 @@ +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import api from "../src/api/index.js"; +import { + CustomError, + MissingParamError, + describeError, +} from "../src/common/error.js"; + +vi.mock(import("../src/common/log.js"), async () => { + const { createLoggerMock } = await import("./utils.js"); + return createLoggerMock(); +}); + +// The handler is a JS function whose inferred options type spells out every +// query parameter. Tests pass partial query maps on purpose and only assert +// the parts of the result they care about, so they call through this +// deliberately narrowed view instead of the raw inferred signature. +interface TestApiResult { + status: string; + error?: { + type?: string; + message?: string; + secondaryMessage?: string; + }; + content: string; +} +const callApi = (options: Record): Promise => { + return api(options as Parameters[0]); +}; + +describe("Test describeError", () => { + it("should return message only for errors without a type", () => { + expect(describeError(new Error("boom"))).toStrictEqual({ + message: "boom", + }); + }); + + it("should return type and message for custom errors", () => { + expect( + describeError( + new CustomError( + "Downtime due to GitHub API rate limiting", + CustomError.MAX_RETRY, + ), + ), + ).toStrictEqual({ + type: CustomError.MAX_RETRY, + message: "Downtime due to GitHub API rate limiting", + secondaryMessage: + "You can deploy own instance or wait until public will be no longer limited", + }); + }); + + it("should omit the type for missing param errors", () => { + expect(describeError(new MissingParamError(["username"]))).toStrictEqual({ + message: + 'Missing params "username" make sure you pass the parameters in URL', + }); + }); + + it("should return a secondary message when available", () => { + expect( + describeError( + new MissingParamError(["username"], "Specify a GitHub username"), + ), + ).toStrictEqual({ + message: + 'Missing params "username" make sure you pass the parameters in URL', + secondaryMessage: "Specify a GitHub username", + }); + }); +}); + +describe("Test API result error contract", () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it("stats handler should keep status stable and attach typed details on rate limit exhaustion", async () => { + mock.onPost("https://api.github.com/graphql").reply(200, { + errors: [{ type: "RATE_LIMITED" }], + }); + + const result = await callApi({ username: "octocat" }); + + // status keeps its exact value for comparisons in apps/backend/router.js + expect(result.status).toBe("error - temporary"); + expect(result).toMatchObject({ + status: "error - temporary", + error: { + type: CustomError.MAX_RETRY, + message: "Downtime due to GitHub API rate limiting", + secondaryMessage: + "You can deploy own instance or wait until public will be no longer limited", + }, + }); + }); +});