From f599b2c84b4d97081e6df403b3e83115253ebab6 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 9 Oct 2026 01:12:20 +0200 Subject: [PATCH] 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 https://git.librete.ch/libretech/mp/issues/71 --- src/logging/error.ts | 30 ++++++++++++++++ tests/unit/log-error.test.ts | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/logging/error.ts create mode 100644 tests/unit/log-error.test.ts diff --git a/src/logging/error.ts b/src/logging/error.ts new file mode 100644 index 0000000..0ea4ea6 --- /dev/null +++ b/src/logging/error.ts @@ -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 + }; +}; diff --git a/tests/unit/log-error.test.ts b/tests/unit/log-error.test.ts new file mode 100644 index 0000000..2407e96 --- /dev/null +++ b/tests/unit/log-error.test.ts @@ -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)); +});