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
3 changes: 3 additions & 0 deletions packages/core/src/api/api-result.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/core/src/api/gist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/pin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/top-langs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/wakatime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/common/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
107 changes: 107 additions & 0 deletions packages/core/tests/describeError.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<TestApiResult> => {
return api(options as Parameters<typeof api>[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",
},
});
});
});