mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
Add dormant provider contracts, qualified patched adapters, tenant-scoped persistence and lifecycle ownership without activating chat routes. Preserve the experimental integration as dependent PR #13038. Co-Authored-By: Paperclip <noreply@paperclip.ing>
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { classifyChatPublicationError } from "./chat-publication-errors.js";
|
|
|
|
function providerError(
|
|
name: string,
|
|
code: string,
|
|
extra: Record<string, unknown> = {},
|
|
) {
|
|
return Object.assign(new Error(`${name} test`), { name, code, ...extra });
|
|
}
|
|
|
|
describe("chat publication error classification", () => {
|
|
it("honors provider rate-limit timing", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("AdapterRateLimitError", "RATE_LIMITED", {
|
|
retryAfter: 42,
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({
|
|
kind: "retry",
|
|
retryAfterMs: 42_000,
|
|
providerRateLimit: true,
|
|
});
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("telegram flood control"), {
|
|
retry_after: 7,
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "delivery_unknown" });
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("RateLimitError", "RATE_LIMITED", {
|
|
retryAfterMs: 1250,
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "retry", retryAfterMs: 1250 });
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("slack rate limit"), {
|
|
code: "slack_webapi_platform_error",
|
|
data: { error: "ratelimited" },
|
|
retryAfter: 3,
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "retry", retryAfterMs: 3000 });
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("You have exceeded a secondary rate limit"), {
|
|
name: "HttpError",
|
|
status: 403,
|
|
response: { headers: { "retry-after": "61" } },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "retry", retryAfterMs: 61_000 });
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("API rate limit exceeded"), {
|
|
name: "HttpError",
|
|
status: 403,
|
|
response: {
|
|
headers: {
|
|
"x-ratelimit-remaining": "0",
|
|
"x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 60),
|
|
},
|
|
},
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "retry" });
|
|
});
|
|
|
|
it("preserves long numeric and HTTP-date provider retry hints", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("AdapterRateLimitError", "RATE_LIMITED", {
|
|
retryAfter: 60 * 60,
|
|
}),
|
|
4,
|
|
),
|
|
).toMatchObject({
|
|
kind: "retry",
|
|
retryAfterMs: 60 * 60 * 1000,
|
|
providerRateLimit: true,
|
|
});
|
|
|
|
const now = Date.now();
|
|
const retryAt = new Date(now + 2 * 60 * 60 * 1000).toUTCString();
|
|
const result = classifyChatPublicationError(
|
|
Object.assign(new Error("rate limited"), {
|
|
status: 429,
|
|
response: { status: 429, headers: { "retry-after": retryAt } },
|
|
}),
|
|
4,
|
|
);
|
|
expect(result).toMatchObject({
|
|
kind: "retry",
|
|
providerRateLimit: true,
|
|
});
|
|
if (result.kind !== "retry") throw new Error("Expected retry");
|
|
expect(result.retryAfterMs).toBeGreaterThanOrEqual(
|
|
2 * 60 * 60 * 1000 - 1_500,
|
|
);
|
|
});
|
|
|
|
it("keeps a rejected Slack WebClient 429 under durable outbox retry control", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("slack web api rate limit"), {
|
|
code: "slack_webapi_rate_limited_error",
|
|
retryAfter: 9,
|
|
}),
|
|
1,
|
|
),
|
|
).toEqual({
|
|
kind: "retry",
|
|
retryAfterMs: 9_000,
|
|
providerRateLimit: true,
|
|
reason: "slack web api rate limit",
|
|
});
|
|
});
|
|
|
|
it("retries endpoint lease contention as a definite pre-transport outcome", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("credential mutation is busy"), {
|
|
status: 409,
|
|
details: { code: "chat_endpoint_credentials_busy" },
|
|
}),
|
|
1,
|
|
),
|
|
).toEqual({
|
|
kind: "retry",
|
|
retryAfterMs: 1_000,
|
|
reason: "credential mutation is busy",
|
|
});
|
|
});
|
|
|
|
it("does not mistake an ordinary GitHub permission denial for rate limiting", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("Resource not accessible by integration"), {
|
|
name: "HttpError",
|
|
status: 403,
|
|
response: { headers: { "x-ratelimit-remaining": "4999" } },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "endpoint_attention" });
|
|
});
|
|
|
|
it("does not call explicit GitHub 4xx rejections ambiguous delivery", () => {
|
|
for (const status of [400, 409, 422]) {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("GitHub rejected the comment"), {
|
|
name: "HttpError",
|
|
status,
|
|
response: { status, headers: {} },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "failed" });
|
|
}
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("GitHub destination is gone"), {
|
|
name: "HttpError",
|
|
status: 410,
|
|
response: { status: 410, headers: {} },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "resource_unavailable" });
|
|
});
|
|
|
|
it("keeps GitHub 5xx responses ambiguous", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("GitHub internal error"), {
|
|
name: "HttpError",
|
|
status: 502,
|
|
response: { status: 502, headers: {} },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "delivery_unknown" });
|
|
});
|
|
|
|
it.each([
|
|
["AuthenticationError", "AUTH_FAILED"],
|
|
["PermissionError", "PERMISSION_DENIED"],
|
|
])("moves definite %s rejections to repair", (name, code) => {
|
|
expect(
|
|
classifyChatPublicationError(providerError(name, code), 1),
|
|
).toMatchObject({
|
|
kind: "endpoint_attention",
|
|
});
|
|
});
|
|
|
|
it("quarantines Telegram 403 destinations without invalidating the bot token", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("PermissionError", "PERMISSION_DENIED", {
|
|
adapter: "telegram",
|
|
action: "sendMessage",
|
|
}),
|
|
1,
|
|
),
|
|
).toEqual({
|
|
kind: "resource_unavailable",
|
|
reason: "PermissionError test",
|
|
});
|
|
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("AuthenticationError", "AUTH_FAILED", {
|
|
adapter: "telegram",
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "endpoint_attention" });
|
|
});
|
|
|
|
it.each(["MessageWritesBlocked", "ForbiddenOperationException"])(
|
|
"quarantines a Teams destination rejected with %s",
|
|
(providerSubCode) => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("PermissionError", "PERMISSION_DENIED", {
|
|
adapter: "teams",
|
|
status: 403,
|
|
subCode: providerSubCode,
|
|
providerCodes: ["Forbidden", providerSubCode],
|
|
details: {
|
|
providerStatus: 403,
|
|
providerSubCode,
|
|
providerCodes: ["Forbidden", providerSubCode],
|
|
},
|
|
}),
|
|
1,
|
|
),
|
|
).toEqual({
|
|
kind: "resource_unavailable",
|
|
reason: "PermissionError test",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps Teams authentication and generic permission failures endpoint-scoped", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("AuthenticationError", "AUTH_FAILED", {
|
|
adapter: "teams",
|
|
status: 401,
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "endpoint_attention" });
|
|
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("PermissionError", "PERMISSION_DENIED", {
|
|
adapter: "teams",
|
|
status: 403,
|
|
providerCodes: ["Authorization_RequestDenied"],
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "endpoint_attention" });
|
|
});
|
|
|
|
it.each([50001, 50013])(
|
|
"quarantines the Discord destination rejected with provider code %s",
|
|
(providerCode) => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("Discord API error: 403"), {
|
|
name: "NetworkError",
|
|
adapter: "discord",
|
|
code: "NETWORK_ERROR",
|
|
status: 403,
|
|
response: { status: 403, headers: {} },
|
|
originalError: {
|
|
name: "DiscordApiError",
|
|
code: providerCode,
|
|
status: 403,
|
|
},
|
|
}),
|
|
1,
|
|
),
|
|
).toEqual({
|
|
kind: "resource_unavailable",
|
|
reason: "Discord API error: 403",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps Discord token and generic app permission failures endpoint-scoped", () => {
|
|
for (const { status, providerCode } of [
|
|
{ status: 401, providerCode: 0 },
|
|
{ status: 403, providerCode: 20012 },
|
|
]) {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error(`Discord API error: ${status}`), {
|
|
name: "NetworkError",
|
|
adapter: "discord",
|
|
code: "NETWORK_ERROR",
|
|
status,
|
|
response: { status, headers: {} },
|
|
originalError: {
|
|
name: "DiscordApiError",
|
|
code: providerCode,
|
|
status,
|
|
},
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "endpoint_attention" });
|
|
}
|
|
});
|
|
|
|
it("marks a missing provider destination unavailable", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("ResourceNotFoundError", "NOT_FOUND"),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "resource_unavailable" });
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("not invited"), {
|
|
code: "slack_webapi_platform_error",
|
|
data: { error: "not_in_channel" },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "resource_unavailable" });
|
|
expect(
|
|
classifyChatPublicationError(
|
|
providerError("NetworkError", "NETWORK_ERROR", {}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "delivery_unknown" });
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(
|
|
new Error(
|
|
"Resource not found during send activity: conversation may no longer exist",
|
|
),
|
|
{ name: "NetworkError", code: "NETWORK_ERROR" },
|
|
),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "resource_unavailable" });
|
|
});
|
|
|
|
it("recognizes definite Slack credential rejection", () => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("invalid auth"), {
|
|
code: "slack_webapi_platform_error",
|
|
data: { error: "invalid_auth" },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "endpoint_attention" });
|
|
});
|
|
|
|
it.each(["is_archived", "channel_is_archived"])(
|
|
"quarantines a Slack destination rejected with %s",
|
|
(platformCode) => {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("Slack rejected the destination"), {
|
|
code: "slack_webapi_platform_error",
|
|
data: { error: platformCode },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "resource_unavailable" });
|
|
},
|
|
);
|
|
|
|
it("fails definite Slack platform rejections without an HTTP status", () => {
|
|
for (const platformCode of ["invalid_blocks", "unknown_provider_code"]) {
|
|
expect(
|
|
classifyChatPublicationError(
|
|
Object.assign(new Error("Slack rejected the request"), {
|
|
code: "slack_webapi_platform_error",
|
|
data: { error: platformCode },
|
|
}),
|
|
1,
|
|
),
|
|
).toMatchObject({ kind: "failed" });
|
|
}
|
|
});
|
|
|
|
it("finds a structured Slack platform rejection through bounded wrappers", () => {
|
|
const platformError = Object.assign(new Error("Slack invalid_blocks"), {
|
|
code: "slack_webapi_platform_error",
|
|
data: { error: "invalid_blocks" },
|
|
});
|
|
const wrapper = Object.assign(new Error("Slack block fallback failed"), {
|
|
cause: platformError,
|
|
});
|
|
platformError.cause = wrapper;
|
|
|
|
expect(classifyChatPublicationError(wrapper, 1)).toMatchObject({
|
|
kind: "failed",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["ValidationError", "VALIDATION_ERROR"],
|
|
["NotImplementedError", "NOT_IMPLEMENTED"],
|
|
["TeamsServiceUrlValidationError", "CHAT_PROVIDER_PRETRANSPORT_REJECTED"],
|
|
["TeamsAdapterCompatibilityError", "CHAT_ADAPTER_COMPATIBILITY_ERROR"],
|
|
])("fails definite non-retryable %s rejections", (name, code) => {
|
|
expect(
|
|
classifyChatPublicationError(providerError(name, code), 1),
|
|
).toMatchObject({
|
|
kind: "failed",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
providerError("NetworkError", "NETWORK_ERROR"),
|
|
new TypeError("fetch failed"),
|
|
new Error("unknown provider transport error"),
|
|
])(
|
|
"requires operator reconciliation for ambiguous transport errors",
|
|
(error) => {
|
|
expect(classifyChatPublicationError(error, 1)).toMatchObject({
|
|
kind: "delivery_unknown",
|
|
});
|
|
},
|
|
);
|
|
});
|