Skip to content

Commit 9ae4314

Browse files
authored
fix(errors): keep meta's code, subcode and both sentences in graph error text (#1095)
Every Meta integration threw away half of each Graph failure: `code` and `error_subcode` had nowhere to go (`ErrorLog` has no columns for them), and the developer-facing `message` was discarded whenever `error_user_msg` was present. `formatGraphError` in `@chatbotx.io/utils` composes all four into the one `text` column the error-log table, the inbox failed-message tooltip and the app logger already read, and replaces the per-integration `pickErrorMessage` copies. Meta already opens most Graph messages with its own `(#100) `, so that copy is stripped rather than printed twice. `channelErrorMessage` now skips an `originError` detail the composed message already contains, for the same reason.
1 parent f92f4f8 commit 9ae4314

18 files changed

Lines changed: 428 additions & 66 deletions

File tree

integrations/facebook-ads/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dependencies": {
77
"@chatbotx.io/logger": "workspace:*",
88
"@chatbotx.io/sdk": "workspace:*",
9+
"@chatbotx.io/utils": "workspace:*",
910
"ky": "^2.0.2",
1011
"libphonenumber-js": "^1.12.42",
1112
"zod": "^4.3.6"

integrations/facebook-ads/src/exception.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SdkException, UNKNOWN_ERROR } from "@chatbotx.io/sdk"
2+
import { formatGraphError } from "@chatbotx.io/utils/graph-error"
23
import { isHTTPError } from "ky"
34
import { facebookAdsLogger } from "./logger"
45

@@ -19,20 +20,6 @@ function asObject<T>(value: unknown): T | undefined {
1920
return typeof value === "object" && value !== null ? (value as T) : undefined
2021
}
2122

22-
// Prefer Facebook's human-readable error (error_user_title/error_user_msg)
23-
// over the terse generic `message` (e.g. "Invalid parameter").
24-
function pickErrorMessage(err?: GraphErrorBody["error"]): string | undefined {
25-
if (!err) {
26-
return
27-
}
28-
if (err.error_user_msg) {
29-
return err.error_user_title
30-
? `${err.error_user_title}: ${err.error_user_msg}`
31-
: err.error_user_msg
32-
}
33-
return err.message
34-
}
35-
3623
export class FacebookAdsException extends SdkException {
3724
constructor(
3825
message: string,
@@ -86,7 +73,7 @@ export const rescue = async <T>(
8673
if (isHTTPError(error)) {
8774
const err = asObject<GraphErrorBody>(error.data)?.error
8875
throw new FacebookAdsException(
89-
pickErrorMessage(err) ?? UNKNOWN_ERROR.message,
76+
formatGraphError(err) ?? UNKNOWN_ERROR.message,
9077
error.response.status,
9178
err?.code ?? "facebookAdsError",
9279
err?.error_subcode,

integrations/instagram-facebook/src/exception.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SdkException } from "@chatbotx.io/sdk"
2+
import { formatGraphError } from "@chatbotx.io/utils/graph-error"
23
import { isHTTPError } from "ky"
34
import { logger } from "./lib/logger"
45

@@ -13,6 +14,8 @@ type ErrorBody = {
1314
code?: number
1415
type?: string
1516
message?: string
17+
error_user_title?: string
18+
error_user_msg?: string
1619
error_subcode?: number | string
1720
subcode?: number | string
1821
}
@@ -37,7 +40,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
3740
code: err?.code,
3841
subCode: err?.error_subcode ?? err?.subcode,
3942
type: err?.type,
40-
message: err?.message,
43+
message: formatGraphError(err),
4144
}
4245
}
4346

@@ -49,7 +52,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
4952
code: err?.code,
5053
subCode: err?.error_subcode ?? err?.subcode,
5154
type: err?.type,
52-
message: err?.message,
55+
message: formatGraphError(err),
5356
}
5457
}
5558

@@ -61,7 +64,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
6164
code: err.code,
6265
subCode: err.error_subcode ?? err.subcode,
6366
type: err.type,
64-
message: err.message,
67+
message: formatGraphError(err),
6568
}
6669
}
6770

integrations/instagram/src/exception.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SdkException } from "@chatbotx.io/sdk"
2+
import { formatGraphError } from "@chatbotx.io/utils/graph-error"
23
import { isHTTPError } from "ky"
34
import { logger } from "./lib/logger"
45

@@ -13,6 +14,8 @@ type ErrorBody = {
1314
code?: number
1415
type?: string
1516
message?: string
17+
error_user_title?: string
18+
error_user_msg?: string
1619
error_subcode?: number | string
1720
subcode?: number | string
1821
}
@@ -37,7 +40,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
3740
code: err?.code,
3841
subCode: err?.error_subcode ?? err?.subcode,
3942
type: err?.type,
40-
message: err?.message,
43+
message: formatGraphError(err),
4144
}
4245
}
4346

@@ -49,7 +52,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
4952
code: err?.code,
5053
subCode: err?.error_subcode ?? err?.subcode,
5154
type: err?.type,
52-
message: err?.message,
55+
message: formatGraphError(err),
5356
}
5457
}
5558

@@ -61,7 +64,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
6164
code: err.code,
6265
subCode: err.error_subcode ?? err.subcode,
6366
type: err.type,
64-
message: err.message,
67+
message: formatGraphError(err),
6568
}
6669
}
6770

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, test } from "vitest"
2+
import { parseOriginError } from "../src/exception"
3+
import { mapToChannelError } from "../src/lib/error-mapper"
4+
5+
/**
6+
* The Graph body Meta returns when a Page is temporarily restricted from
7+
* messaging a thread. Every piece of it has to survive into `ErrorLog.detail`:
8+
* code 10 alone covers a dozen unrelated permission failures, and the subcode
9+
* is the only thing that names this one.
10+
*/
11+
const RESTRICTED_THREAD_ERROR = {
12+
message: "Application does not have permission for this action",
13+
type: "OAuthException",
14+
code: 10,
15+
error_subcode: 1_893_063,
16+
error_user_title: "Bạn không thể gửi tin nhắn đến cuộc trò chuyện này",
17+
error_user_msg:
18+
"Bạn tạm thời bị hạn chế gửi tin nhắn. Tìm hiểu thêm về thời gian và lý do chúng tôi hạn chế khả năng nhắn tin.",
19+
fbtrace_id: "Ae1wFHiG1XvSn1TtWwjWxPZ",
20+
}
21+
22+
const EXPECTED_DETAIL =
23+
"#(10 - 1893063) Application does not have permission for this action. Bạn tạm thời bị hạn chế gửi tin nhắn. Tìm hiểu thêm về thời gian và lý do chúng tôi hạn chế khả năng nhắn tin."
24+
25+
describe("parseOriginError message composition", () => {
26+
test("keeps the code pair and both sentences of a restricted-thread failure", () => {
27+
expect(
28+
parseOriginError({
29+
httpStatus: 403,
30+
errorBody: { error: RESTRICTED_THREAD_ERROR },
31+
}),
32+
).toMatchObject({
33+
httpStatusCode: 403,
34+
code: 10,
35+
subCode: 1_893_063,
36+
type: "OAuthException",
37+
message: EXPECTED_DETAIL,
38+
})
39+
})
40+
41+
test("composes the same string from the explicit response shape", () => {
42+
expect(
43+
parseOriginError({ response: { error: RESTRICTED_THREAD_ERROR } }),
44+
).toMatchObject({ message: EXPECTED_DETAIL })
45+
})
46+
47+
test("falls back to the user title when Meta sent no user message", () => {
48+
expect(
49+
parseOriginError({
50+
httpStatus: 400,
51+
errorBody: {
52+
error: {
53+
message: "Invalid parameter",
54+
code: 100,
55+
error_subcode: 2_018_001,
56+
error_user_title: "Message not sent",
57+
},
58+
},
59+
}),
60+
).toMatchObject({
61+
message: "#(100 - 2018001) Invalid parameter. Message not sent",
62+
})
63+
})
64+
65+
test("adds no prefix to a failure that never reached Graph", () => {
66+
expect(parseOriginError(new Error("socket hang up"))).toMatchObject({
67+
httpStatusCode: 400,
68+
message: "socket hang up",
69+
})
70+
})
71+
})
72+
73+
describe("the string that reaches ErrorLog.detail", () => {
74+
/**
75+
* `toEntry` in `@chatbotx.io/business` writes `error.message` verbatim into
76+
* `ErrorLog.detail`, and `parseSdkError` is what hands it over. Nothing
77+
* between here and the row re-derives the message, so pinning it at
78+
* `getErrorData()` pins the column.
79+
*/
80+
test("survives mapToChannelError and getErrorData unchanged", async () => {
81+
const channelError = mapToChannelError({
82+
httpStatus: 403,
83+
errorBody: { error: RESTRICTED_THREAD_ERROR },
84+
})
85+
86+
expect(channelError.message).toBe(EXPECTED_DETAIL)
87+
88+
const errorData = await channelError.getErrorData()
89+
expect(errorData).toMatchObject({
90+
message: EXPECTED_DETAIL,
91+
code: 10,
92+
subcode: 1_893_063,
93+
statusCode: 403,
94+
// Terminal, so `recordProviderErrorLog` writes the row on this emission.
95+
isRetryable: false,
96+
})
97+
})
98+
})

integrations/messenger/src/exception.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SdkException } from "@chatbotx.io/sdk"
2+
import { formatGraphError } from "@chatbotx.io/utils/graph-error"
23
import { isHTTPError } from "ky"
34
import { logger } from "./lib/logger"
45

@@ -22,20 +23,6 @@ type ErrorBody = {
2223
type OriginShape = { httpStatus?: number; errorBody?: ErrorBody }
2324
type ExplicitShape = { response?: { error?: ErrorBody["error"] } }
2425

25-
// Prefer Facebook's human-readable error (error_user_title/error_user_msg) over
26-
// the terse generic `message` (e.g. "Invalid parameter").
27-
function pickErrorMessage(err?: ErrorBody["error"]): string | undefined {
28-
if (!err) {
29-
return
30-
}
31-
if (err.error_user_msg) {
32-
return err.error_user_title
33-
? `${err.error_user_title}: ${err.error_user_msg}`
34-
: err.error_user_msg
35-
}
36-
return err.message
37-
}
38-
3926
export type ChannelErrorSource = {
4027
httpStatusCode: number
4128
code?: number | string
@@ -53,7 +40,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
5340
code: err?.code,
5441
subCode: err?.error_subcode ?? err?.subcode,
5542
type: err?.type,
56-
message: pickErrorMessage(err),
43+
message: formatGraphError(err),
5744
}
5845
}
5946

@@ -65,7 +52,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
6552
code: err?.code,
6653
subCode: err?.error_subcode ?? err?.subcode,
6754
type: err?.type,
68-
message: pickErrorMessage(err),
55+
message: formatGraphError(err),
6956
}
7057
}
7158

@@ -78,7 +65,7 @@ export function parseOriginError(originError: unknown): ChannelErrorSource {
7865
code: err.code,
7966
subCode: err.error_subcode ?? err.subcode,
8067
type: err.type,
81-
message: pickErrorMessage(err),
68+
message: formatGraphError(err),
8269
}
8370
}
8471
return {

integrations/meta-catalog/__tests__/http-client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe("metaCatalogGraphClient error mapping", () => {
4646
metaCatalogGraphClient.get("v24.0/catalog-1/products"),
4747
).rejects.toMatchObject({
4848
message:
49-
"Invalid parameter This catalog is not connected to a commerce account yet.",
49+
"#(100 - 33) Invalid parameter. This catalog is not connected to a commerce account yet.",
5050
fbTraceId: "trace-1",
5151
graphCode: 100,
5252
graphSubcode: 33,

integrations/meta-catalog/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"@chatbotx.io/integration-messenger": "workspace:*",
99
"@chatbotx.io/logger": "workspace:*",
1010
"@chatbotx.io/sdk": "workspace:*",
11+
"@chatbotx.io/utils": "workspace:*",
1112
"ky": "^2.0.2",
1213
"zod": "^4.3.6"
1314
},

integrations/meta-catalog/src/lib/http-client.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { formatGraphError } from "@chatbotx.io/utils/graph-error"
12
import ky, { isHTTPError, type KyInstance } from "ky"
23
import { GRAPH_API_URL } from "../constants"
34
import { MetaCatalogException } from "../exception"
@@ -38,26 +39,6 @@ type GraphErrorBody = {
3839
}
3940
}
4041

41-
/**
42-
* Graph's `message` is written for developers ("Invalid parameter"); when it
43-
* also sends `error_user_msg` that one says what to actually do about it. Both
44-
* end up in the exception because the pair is what makes a failure in the sync
45-
* history diagnosable — dropping either leaves the workspace guessing.
46-
*/
47-
const graphErrorMessage = (
48-
error: GraphErrorBody["error"],
49-
): string | undefined => {
50-
const parts = [
51-
error?.message,
52-
error?.error_user_msg ?? error?.error_user_title,
53-
]
54-
.map((part) => part?.trim())
55-
.filter((part): part is string => Boolean(part))
56-
// Graph sometimes repeats itself across the two fields; a Set keeps the
57-
// sentence from being printed twice.
58-
return [...new Set(parts)].join(" — ") || undefined
59-
}
60-
6142
export type GraphResponse<T> = {
6243
data: T
6344
businessUsageHeader: string | null
@@ -101,7 +82,7 @@ class MetaCatalogHttpClient {
10182
// "Body has already been consumed" and bury the real Graph error.
10283
const body: GraphErrorBody = isRecord(error.data) ? error.data : {}
10384
throw new MetaCatalogException(
104-
graphErrorMessage(body.error) ?? `Meta Graph request failed: ${url}`,
85+
formatGraphError(body.error) ?? `Meta Graph request failed: ${url}`,
10586
error.response.status,
10687
body.error?.code,
10788
{

integrations/meta-conversions/src/exception.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { formatGraphErrorMessage } from "@chatbotx.io/utils/graph-error"
2+
13
const FALLBACK_HTTP_STATUS = 400
24

35
type MetaErrorData = { details?: string }
@@ -130,7 +132,16 @@ export class MetaConversionsException extends Error {
130132
),
131133
originError?: unknown,
132134
) {
133-
super(source.message ?? "Meta Conversions API call failed")
135+
// The readonly fields below still carry each piece on its own; this is
136+
// where they become the one string every surface reads.
137+
super(
138+
formatGraphErrorMessage({
139+
code: source.code,
140+
subCode: source.subCode,
141+
message: source.message,
142+
userMessage: source.userMessage ?? source.userTitle,
143+
}) ?? "Meta Conversions API call failed",
144+
)
134145
this.name = "MetaConversionsException"
135146
this.httpStatusCode = source.httpStatusCode
136147
this.code = source.code ?? "metaConversionsError"

0 commit comments

Comments
 (0)