feat(logging): describe an error by its name, message and status code
describeError keeps an error's name, the first line of its message, cut to 200 characters, and its HTTP status code. An error of the PayPal SDK or of axios carries its request, whose Authorization header holds the PayPal client credentials or an access token, so the error object itself must not reach the log. The SDK's response validation error quotes the response values on the lines after the first. Refs libretech/mp#71
This commit is contained in:
2 files changed
+97
No files matched your search
@@ -0,0 +1,30 @@
|
||||
/** What is logged of an error. */
|
||||
export type ErrorSummary = {
|
||||
name: string;
|
||||
message: string;
|
||||
statusCode: number | undefined;
|
||||
};
|
||||
|
||||
// A message is cut to its first line and to this length: the PayPal SDK's ResponseValidationError quotes the response values it
|
||||
// rejected on the lines after its first.
|
||||
const MAX_LENGTH = 200;
|
||||
|
||||
const cut = (text: string): string => {
|
||||
const line = text.split("\n")[0];
|
||||
return line.length > MAX_LENGTH ? `${line.slice(0, MAX_LENGTH)}…` : line;
|
||||
};
|
||||
|
||||
/**
|
||||
* The name, the first line of the message and the HTTP status code of an error, and nothing else. An error of the PayPal SDK
|
||||
* (ApiError) or of axios carries its request, whose Authorization header holds the PayPal client credentials or an access token,
|
||||
* so the error object itself is never logged.
|
||||
*/
|
||||
export const describeError = (error: unknown): ErrorSummary => {
|
||||
if (typeof error !== "object" || error === null) return { name: typeof error, message: "", statusCode: undefined };
|
||||
const { name, message, statusCode } = error as { name?: unknown; message?: unknown; statusCode?: unknown };
|
||||
return {
|
||||
name: typeof name === "string" ? cut(name) : "object",
|
||||
message: typeof message === "string" ? cut(message) : "",
|
||||
statusCode: typeof statusCode === "number" ? statusCode : undefined
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { describeError } from "../../src/logging/error.ts";
|
||||
import { formatLogLine } from "../../src/logging/format.ts";
|
||||
|
||||
const TIMESTAMP = "2026-10-08 21:29:40.426";
|
||||
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
|
||||
|
||||
// What axios throws when PayPal cannot be reached: the request config with its headers.
|
||||
const networkError = () =>
|
||||
Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
|
||||
name: "AxiosError",
|
||||
code: "EAI_AGAIN",
|
||||
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
|
||||
});
|
||||
|
||||
test("keeps the name, message and status code of a PayPal SDK error, not its request, headers or body", () => {
|
||||
// @apimatic/core's ApiError, which the SDK's CustomError and OAuthProviderError extend
|
||||
const error = Object.assign(new Error("OAuth 2 Authorization endpoint returned error."), {
|
||||
request: { method: "POST", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } },
|
||||
statusCode: 401,
|
||||
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
|
||||
body: '{"error":"invalid_client","error_description":"Client Authentication failed"}'
|
||||
});
|
||||
|
||||
assert.deepEqual(describeError(error), { name: "Error", message: "OAuth 2 Authorization endpoint returned error.", statusCode: 401 });
|
||||
});
|
||||
|
||||
test("keeps the name and message of an axios network error, not its config", () => {
|
||||
assert.deepEqual(describeError(networkError()), { name: "AxiosError", message: "getaddrinfo EAI_AGAIN api-m.paypal.com", statusCode: undefined });
|
||||
});
|
||||
|
||||
test("keeps the first line of a message: the SDK's response validation error quotes the response after it", () => {
|
||||
const error = new Error(
|
||||
'The response did not match the response schema.\n\n> Issue #1\n\nExpected value to be of type \'string\'.\n\nGiven value: {"email_address":"erika.buyer@example.org"}'
|
||||
);
|
||||
|
||||
assert.deepEqual(describeError(error), { name: "Error", message: "The response did not match the response schema.", statusCode: undefined });
|
||||
});
|
||||
|
||||
test("cuts a long message", () => {
|
||||
assert.equal(describeError(new Error("x".repeat(500))).message, `${"x".repeat(200)}…`);
|
||||
});
|
||||
|
||||
test("describes a thrown value that is not an error by its type alone", () => {
|
||||
assert.deepEqual(describeError("/result/error"), { name: "string", message: "", statusCode: undefined });
|
||||
assert.deepEqual(describeError(undefined), { name: "undefined", message: "", statusCode: undefined });
|
||||
assert.deepEqual(describeError(null), { name: "object", message: "", statusCode: undefined });
|
||||
assert.deepEqual(describeError({ statusCode: "500" }), { name: "object", message: "", statusCode: undefined });
|
||||
});
|
||||
|
||||
test("the log line of the description holds no credentials", () => {
|
||||
// Compare log-format.test.ts: the line of the error object itself holds them, a known defect of the formatter.
|
||||
const line = formatLogLine({
|
||||
timestamp: TIMESTAMP,
|
||||
level: "error",
|
||||
message: "app:e:order-controller: PayPal did not capture the payment",
|
||||
order: 4711,
|
||||
error: describeError(networkError())
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
line,
|
||||
'2026-10-08 21:29:40.426Z error: app:e:order-controller: PayPal did not capture the payment {\n "order": 4711,\n "error": {\n "name": "AxiosError",\n "message": "getaddrinfo EAI_AGAIN api-m.paypal.com"\n }\n}'
|
||||
);
|
||||
assert.ok(!line.includes(CREDENTIALS));
|
||||
});
|
||||
Reference in new issue
Block a user