Skip to content

Commit c538d2b

Browse files
authored
Merge pull request #9 from wuespace/localized-http-exception
Add `LocalizedHttpException` for nice, localized error handling
2 parents b4e8173 + 2c9b364 commit c538d2b

4 files changed

Lines changed: 195 additions & 1 deletion

File tree

example/main.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Hono } from "@hono/hono";
22
import { jsxRenderer } from "@hono/hono/jsx-renderer";
3-
import { lt, t, useLocale } from "@wuespace/honolate";
3+
import { LocalizedHttpException, lt, t, useLocale } from "@wuespace/honolate";
44
import { withI18n } from "./i18n.ts";
55

66
const lazyTerm = lt`Lazy term`;
@@ -10,6 +10,21 @@ export const app = new Hono()
1010
withI18n,
1111
jsxRenderer(),
1212
)
13+
.onError((err, c) => {
14+
const localizedError = LocalizedHttpException.fromCause(err);
15+
console.error("[app]", localizedError);
16+
return c.json({
17+
errorId: localizedError.errorId,
18+
title: t(localizedError.options.localizedTitle ?? lt`Unknown Error`),
19+
message: t(
20+
localizedError.options.localizedMessage ??
21+
lt`An unexpected error occurred while processing your request.`,
22+
),
23+
technicalMessage: localizedError.options.technicalMessage,
24+
}, {
25+
status: localizedError.status,
26+
});
27+
})
1328
.get("/text", (c) => c.text(t`Hello world!`))
1429
.get("/locale", (c) => c.text(useLocale()))
1530
.get("/render", (c) => c.render(<h1>{t`Hello world!`}</h1>))
@@ -27,6 +42,14 @@ export const app = new Hono()
2742
"/escape-test",
2843
(c) => c.text(t`This text contains ${1} {} curly braces and \\{{}}{0}.`),
2944
)
45+
.get("/trigger-500", () => {
46+
throw new LocalizedHttpException({
47+
status: 500,
48+
localizedTitle: lt`Hello world!`,
49+
localizedMessage: lt`Untranslated text`,
50+
technicalMessage: "Technical message",
51+
});
52+
})
3053
.notFound((c) => c.text("Not Found", 404));
3154

3255
import.meta.main && Deno.serve(app.fetch);

lib/hono/LocalizedHttpException.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { HTTPException } from "@hono/hono/http-exception";
2+
import type { LazyLocalizedString } from "./LazyLocalizedString.ts";
3+
4+
/**
5+
* Options for creating a LocalizedHttpException.
6+
*/
7+
export interface LocalizedHttpExceptionOptions {
8+
/**
9+
* Technical message for this error. Logged for debugging purposes.
10+
* This is the only message guaranteed to be logged.
11+
*/
12+
readonly technicalMessage?: string;
13+
/**
14+
* HTTP status code to be used for this error.
15+
* Defaults to 500 (Internal Server Error).
16+
*/
17+
readonly status?: HTTPException["status"];
18+
/**
19+
* Optional cause of this error. Logged for debugging purposes.
20+
*/
21+
readonly cause?: unknown;
22+
/**
23+
* Localized title for this error. Shown to the user.
24+
*/
25+
readonly localizedTitle?: LazyLocalizedString;
26+
/**
27+
* Localized message for this error. Shown to the user.
28+
*/
29+
readonly localizedMessage?: LazyLocalizedString;
30+
}
31+
32+
/**
33+
* An HTTP exception that supports localization.
34+
*
35+
* Contains both technical details for logging and user-friendly localized messages.
36+
*
37+
* Use {@link fromCause} to convert arbitrary errors / exceptions into LocalizedHttpExceptions.
38+
*
39+
* By logging the LocalizedHttpException, the technical message and cause are preserved for debugging purposes.
40+
* Logs and user messages can be correlated using the {@link errorId}.
41+
*
42+
* See {@link LocalizedHttpExceptionOptions} for details on the available options.
43+
*
44+
* @example
45+
*
46+
* ```ts
47+
* throw new LocalizedHttpException({
48+
* technicalMessage: "Database connection failed",
49+
* status: 503,
50+
* localizedTitle: lt`Service Unavailable`,
51+
* localizedMessage: lt`The service is currently unavailable. Please try again later.`,
52+
* });
53+
* ```
54+
*
55+
* @example
56+
*
57+
* ```ts
58+
* router.onError((err, c) => {
59+
* const localizedError = LocalizedHttpException.fromCause(err);
60+
* console.error("[router]", localizedError);
61+
* c.status(localizedError.status || 500);
62+
* return c.render(
63+
* <>
64+
* <h1>{ t(localizedError.options.localizedTitle ?? lt`Unknown Error`)}</h1>
65+
* <p>{ t(localizedError.options.localizedMessage ?? lt`An unexpected error occurred while processing your request.`) }</p>
66+
* <p>{ t(`Please specify the following error ID when contacting support:`) }</p>
67+
* <pre><code>{ localizedError.errorId }</code></pre>
68+
* </>
69+
* );
70+
* });
71+
* ```
72+
*/
73+
export class LocalizedHttpException extends HTTPException {
74+
/**
75+
* Unique error ID for this exception instance.
76+
* Used for correlating logs with user reports.
77+
*
78+
* Format: ISO timestamp + "-" + first 8 characters of a UUIDv4
79+
*/
80+
public readonly errorId: string = new Date().toISOString() + "-" +
81+
crypto.randomUUID().substring(0, 8);
82+
83+
/**
84+
* Creates a new LocalizedHttpException.
85+
* Use {@link fromCause} to convert arbitrary errors / exceptions into LocalizedHttpExceptions.
86+
* @param options the options for the error. See {@link LocalizedHttpExceptionOptions} for details
87+
*/
88+
constructor(
89+
public readonly options: LocalizedHttpExceptionOptions = {},
90+
) {
91+
super(options.status ?? 500, {
92+
cause: options.cause,
93+
});
94+
this.message = options.technicalMessage
95+
? `<${this.errorId}> ${options.technicalMessage}`
96+
: `<${this.errorId}>`;
97+
}
98+
99+
/**
100+
* Converts an arbitrary error into a LocalizedHttpException.
101+
*
102+
* Useful for error handlers that can receive any kind of error.
103+
* After conversion, the returned LocalizedHttpException can be used to
104+
* retrieve localized messages for the user.
105+
*
106+
* By logging the returned LocalizedHttpException, the technical message
107+
* and cause are preserved for debugging purposes.
108+
* Logs and user messages can be correlated using the {@link errorId}.
109+
* @param error the original error
110+
* @returns a corresponding {@link LocalizedHttpException}
111+
*/
112+
static fromCause(
113+
error: unknown,
114+
): LocalizedHttpException {
115+
if (error instanceof LocalizedHttpException) {
116+
return error;
117+
}
118+
if (error instanceof HTTPException) {
119+
return new LocalizedHttpException({
120+
technicalMessage: error.message,
121+
status: error.status,
122+
cause: error,
123+
});
124+
}
125+
return new LocalizedHttpException({
126+
technicalMessage: "Unknown error (see cause for details)",
127+
cause: error,
128+
});
129+
}
130+
}

lib/mod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ export { runCLI } from "./extract/runCLI.ts";
77
export { useLocale } from "./hono/useLocale.ts";
88
export type { LocalizedStringValue } from "./hono/LocalizedStringValue.ts";
99
export type { LazyLocalizedString } from "./hono/LazyLocalizedString.ts";
10+
export {
11+
LocalizedHttpException,
12+
type LocalizedHttpExceptionOptions,
13+
} from "./hono/LocalizedHttpException.ts";

tests/hono-e2e.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,43 @@ describe("Hono + Honolate E2E", () => {
7272
);
7373
});
7474
});
75+
describe("Error handling and LocalizedHttpException", () => {
76+
const origConsoleLog = console.error;
77+
const logs = new Set();
78+
function before() {
79+
console.error = (...args) => {
80+
logs.add(args.join("\n"));
81+
};
82+
}
83+
function after() {
84+
logs.clear();
85+
console.error = origConsoleLog;
86+
}
87+
88+
it("GET /trigger-500 should return localized error response", async () => {
89+
before();
90+
// @ts-expect-error testing purposes
91+
const resDefault = await (await testApp["trigger-500"].$get({})).json();
92+
// @ts-expect-error testing purposes
93+
const resEn = await (await testApp["trigger-500"].$get({
94+
query: { lang: "en" },
95+
})).json();
96+
// @ts-expect-error testing purposes
97+
const resDe = await (await testApp["trigger-500"].$get({
98+
query: { lang: "de" },
99+
})).json();
100+
101+
expect(resDefault.title).toBe("Hello world!");
102+
expect(resDefault.message).toBe("Untranslated text");
103+
expect(resEn.title).toBe("Hello world!");
104+
expect(resEn.message).toBe("Untranslated text");
105+
expect(resDe.title).toBe("Hallo Welt!");
106+
expect(resDe.message).toBe("Untranslated text");
107+
108+
expect(logs.size).toBe(3);
109+
after();
110+
});
111+
});
75112
describe("Locale", () => {
76113
it("GET /locale should return correct locale", async () => {
77114
const resDefault = await (await testApp.locale.$get({})).text();

0 commit comments

Comments
 (0)