From ea62aa30e2b8ad96e0dfbde8f1d715d23db63b38 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 6 May 2026 17:05:46 -0500 Subject: [PATCH] Redact AWS remote import provider errors Co-Authored-By: Paperclip --- .../aws-secrets-manager-provider.test.ts | 52 ++++++++ server/src/__tests__/secrets-routes.test.ts | 27 +++- server/src/__tests__/secrets-service.test.ts | 86 +++++++++++++ .../secrets/aws-secrets-manager-provider.ts | 54 ++++++-- server/src/secrets/types.ts | 58 +++++++++ server/src/services/secrets.ts | 118 ++++++++++++++++-- .../secrets/ImportFromVaultDialog.test.tsx | 49 +++++++- .../pages/secrets/ImportFromVaultDialog.tsx | 16 ++- 8 files changed, 437 insertions(+), 23 deletions(-) diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index 19d122f377e..e1ca5ebc098 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; +import { SecretProviderClientError } from "../secrets/types.js"; describe("awsSecretsManagerProvider", () => { const previousEnv = { @@ -398,6 +399,57 @@ describe("awsSecretsManagerProvider", () => { expect(JSON.stringify(listed)).not.toContain("team"); }); + it("redacts AWS provider exception text when remote listing fails", async () => { + const rawProviderMessage = + "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai"; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + gateway: { + async createSecret() { + throw new Error("not used"); + }, + async putSecretValue() { + throw new Error("not used"); + }, + async getSecretValue() { + throw new Error("not used"); + }, + async deleteSecret() { + throw new Error("not used"); + }, + async listSecrets() { + throw new Error(rawProviderMessage); + }, + }, + }); + + let thrown: unknown; + try { + await provider.listRemoteSecrets?.({}); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(SecretProviderClientError); + expect(thrown).toMatchObject({ + code: "access_denied", + status: 403, + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + rawMessage: rawProviderMessage, + }); + expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws"); + expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("123456789012"); + }); + it("resolves AWS secret values by provider version reference", async () => { const calls: Array<{ op: string; input: Record }> = []; const provider = createAwsSecretsManagerProvider({ diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index a6a28163e64..039289b9e00 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -3,7 +3,7 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { secretRoutes } from "../routes/secrets.js"; import { errorHandler } from "../middleware/error-handler.js"; -import { unprocessable } from "../errors.js"; +import { HttpError, unprocessable } from "../errors.js"; const mockSecretService = vi.hoisted(() => ({ listProviders: vi.fn(), @@ -301,6 +301,31 @@ describe("secret routes", () => { expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai"); }); + it("returns sanitized remote import preview provider errors", async () => { + mockSecretService.previewRemoteImport.mockRejectedValue( + new HttpError( + 403, + "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + { code: "access_denied" }, + ), + ); + + const res = await request(createApp()) + .post("/api/companies/company-1/secrets/remote-import/preview") + .send({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + details: { code: "access_denied" }, + }); + expect(JSON.stringify(res.body)).not.toContain("arn:aws"); + expect(JSON.stringify(res.body)).not.toContain("123456789012"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("imports remote references and logs aggregate row counts", async () => { mockSecretService.importRemoteSecrets.mockResolvedValue({ providerConfigId: "11111111-1111-4111-8111-111111111111", diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 8d6059bb8e5..5a22f0dd5b4 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -16,6 +16,7 @@ import { } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; +import { SecretProviderClientError } from "../secrets/types.js"; import { secretService } from "../services/secrets.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -448,6 +449,44 @@ describeEmbeddedPostgres("secretService", () => { expect(preview.candidates[2]?.providerMetadata).toBeNull(); }); + it("sanitizes AWS remote import preview provider errors before crossing the service boundary", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const awsVault = await svc.createProviderConfig(companyId, { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }); + const rawProviderMessage = + "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets"; + + vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockRejectedValueOnce( + new SecretProviderClientError({ + code: "access_denied", + provider: "aws_secrets_manager", + operation: "listSecrets", + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + rawMessage: rawProviderMessage, + }), + ); + + let thrown: unknown; + try { + await svc.previewRemoteImport(companyId, { providerConfigId: awsVault.id }); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + status: 403, + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + details: { code: "access_denied" }, + }); + expect(JSON.stringify(thrown)).not.toContain("arn:aws"); + expect(JSON.stringify(thrown)).not.toContain("123456789012"); + expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws"); + }); + it("imports AWS remote references row-by-row without fetching plaintext", async () => { const companyId = await seedCompany(); const svc = secretService(db); @@ -519,6 +558,53 @@ describeEmbeddedPostgres("secretService", () => { expect(JSON.stringify(versions[0])).not.toContain("sk-"); }); + it("sanitizes AWS remote import row provider errors", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const awsVault = await svc.createProviderConfig(companyId, { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }); + const rawProviderMessage = + "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai"; + vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce( + new SecretProviderClientError({ + code: "access_denied", + provider: "aws_secrets_manager", + operation: "linkExternalSecret", + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + rawMessage: rawProviderMessage, + }), + ); + + const result = await svc.importRemoteSecrets(companyId, { + providerConfigId: awsVault.id, + secrets: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "OpenAI API key", + key: "openai-api-key", + }, + ], + }); + + expect(result).toMatchObject({ + importedCount: 0, + skippedCount: 0, + errorCount: 1, + results: [ + expect.objectContaining({ + status: "error", + reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + }), + ], + }); + expect(JSON.stringify(result)).not.toContain(rawProviderMessage); + expect(JSON.stringify(result.results[0]?.reason)).not.toContain("arn:aws"); + expect(JSON.stringify(result.results[0]?.reason)).not.toContain("123456789012"); + }); + it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts index c1f0a08ed7a..9ca8c06fba9 100644 --- a/server/src/secrets/aws-secrets-manager-provider.ts +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -4,10 +4,11 @@ import { Hash } from "@smithy/hash-node"; import { HttpRequest } from "@smithy/protocol-http"; import { SignatureV4 } from "@smithy/signature-v4"; import type { DeploymentMode } from "@paperclipai/shared"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { unprocessable } from "../errors.js"; import type { PreparedSecretVersion, RemoteSecretListResult, + SecretProviderClientErrorCode, SecretProviderHealthCheck, SecretProviderModule, SecretProviderValidationResult, @@ -15,6 +16,7 @@ import type { SecretProviderWriteContext, StoredSecretVersionMaterial, } from "./types.js"; +import { SecretProviderClientError } from "./types.js"; const AWS_SECRETS_MANAGER_SCHEME = "aws_secrets_manager_v1"; const DEFAULT_PREFIX = "paperclip"; @@ -464,15 +466,49 @@ function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSec throw unprocessable("Invalid AWS Secrets Manager material"); } +function classifyAwsProviderError(message: string): SecretProviderClientErrorCode { + if (/ResourceExistsException|AlreadyExists/i.test(message)) return "conflict"; + if (/ResourceNotFoundException|NotFound/i.test(message)) return "not_found"; + if (/AccessDeniedException|AccessDenied|UnrecognizedClientException|InvalidClientTokenId|not authorized/i.test(message)) { + return "access_denied"; + } + if (/Throttl|TooManyRequests|RequestLimitExceeded|Rate exceeded/i.test(message)) return "throttled"; + if (/ValidationException|InvalidParameter|InvalidRequest/i.test(message)) return "invalid_request"; + if (/fetch failed|ECONN|ENOTFOUND|ETIMEDOUT|network|timeout/i.test(message)) return "provider_unavailable"; + return "provider_error"; +} + +function awsProviderSafeMessage(code: SecretProviderClientErrorCode): string { + switch (code) { + case "access_denied": + return "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault."; + case "throttled": + return "AWS Secrets Manager throttled the request. Wait and try again."; + case "not_found": + return "AWS Secrets Manager could not find the requested secret."; + case "conflict": + return "AWS Secrets Manager reported that the requested secret already exists."; + case "invalid_request": + return "AWS Secrets Manager rejected the request."; + case "provider_unavailable": + return "AWS Secrets Manager is unavailable right now."; + case "provider_error": + default: + return "AWS Secrets Manager request failed."; + } +} + function normalizeAwsError(operation: string, error: unknown): never { - const message = error instanceof Error ? error.message : String(error); - if (/ResourceExistsException/i.test(message)) { - throw conflict(`AWS Secrets Manager ${operation} conflict: ${message}`); - } - if (/ResourceNotFoundException/i.test(message)) { - throw notFound(`AWS Secrets Manager ${operation} failed: ${message}`); - } - throw unprocessable(`AWS Secrets Manager ${operation} failed: ${message}`); + const rawMessage = error instanceof Error ? error.message : String(error); + const code = classifyAwsProviderError(rawMessage); + throw new SecretProviderClientError({ + code, + provider: "aws_secrets_manager", + operation, + message: awsProviderSafeMessage(code), + rawMessage, + cause: error, + }); } class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway { diff --git a/server/src/secrets/types.ts b/server/src/secrets/types.ts index 461063f56d0..341163e6bec 100644 --- a/server/src/secrets/types.ts +++ b/server/src/secrets/types.ts @@ -41,6 +41,64 @@ export interface RemoteSecretListResult { nextToken?: string | null; } +export type SecretProviderClientErrorCode = + | "access_denied" + | "throttled" + | "not_found" + | "conflict" + | "invalid_request" + | "provider_unavailable" + | "provider_error"; + +export interface SecretProviderClientErrorOptions { + code: SecretProviderClientErrorCode; + provider: SecretProvider; + operation: string; + message: string; + status?: number; + rawMessage?: string | null; + cause?: unknown; +} + +const SECRET_PROVIDER_CLIENT_ERROR_STATUS: Record = { + access_denied: 403, + throttled: 429, + not_found: 404, + conflict: 409, + invalid_request: 422, + provider_unavailable: 503, + provider_error: 502, +}; + +export class SecretProviderClientError extends Error { + readonly code: SecretProviderClientErrorCode; + readonly provider: SecretProvider; + readonly operation: string; + readonly status: number; + readonly rawMessage: string | null; + + constructor(options: SecretProviderClientErrorOptions) { + super(options.message); + this.name = "SecretProviderClientError"; + this.code = options.code; + this.provider = options.provider; + this.operation = options.operation; + this.status = options.status ?? SECRET_PROVIDER_CLIENT_ERROR_STATUS[options.code]; + this.rawMessage = options.rawMessage ?? null; + if (options.cause !== undefined) { + Object.defineProperty(this, "cause", { + value: options.cause, + enumerable: false, + configurable: true, + }); + } + } +} + +export function isSecretProviderClientError(error: unknown): error is SecretProviderClientError { + return error instanceof SecretProviderClientError; +} + export interface SecretProviderRuntimeContext { companyId: string; secretId: string; diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 5503dc41424..7365f80aa48 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -36,7 +36,8 @@ import { secretProviderConfigPayloadSchema, updateSecretProviderConfigSchema, } from "@paperclipai/shared"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { checkSecretProviders, getSecretProvider, @@ -44,9 +45,11 @@ import { } from "../secrets/provider-registry.js"; import type { PreparedSecretVersion, + RemoteSecretListResult, SecretProviderHealthCheck, SecretProviderVaultRuntimeConfig, } from "../secrets/types.js"; +import { isSecretProviderClientError } from "../secrets/types.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = @@ -57,6 +60,76 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ "vault", ]); +function remoteProviderHttpError(error: unknown, context: { + companyId: string; + provider: SecretProvider; + providerConfigId: string; + operation: string; +}): HttpError { + if (isSecretProviderClientError(error)) { + logger.warn( + { + err: error, + companyId: context.companyId, + provider: context.provider, + providerConfigId: context.providerConfigId, + operation: context.operation, + providerErrorCode: error.code, + }, + "remote secret provider request failed", + ); + return new HttpError(error.status, error.message, { code: error.code }); + } + if (error instanceof HttpError) return error; + logger.warn( + { + err: error, + companyId: context.companyId, + provider: context.provider, + providerConfigId: context.providerConfigId, + operation: context.operation, + providerErrorCode: "provider_error", + }, + "remote secret provider request failed", + ); + return new HttpError(502, "Remote secret provider request failed.", { code: "provider_error" }); +} + +function remoteImportRowFailureReason(error: unknown, fallback: string, context: { + companyId: string; + provider: SecretProvider; + providerConfigId: string; + operation: string; +}): string { + if (isSecretProviderClientError(error)) { + logger.warn( + { + err: error, + companyId: context.companyId, + provider: context.provider, + providerConfigId: context.providerConfigId, + operation: context.operation, + providerErrorCode: error.code, + }, + "remote secret import row provider failure", + ); + return error.message; + } + if (error instanceof HttpError && error.status < 500) return error.message; + logger.warn( + { + err: error, + companyId: context.companyId, + provider: context.provider, + providerConfigId: context.providerConfigId, + operation: context.operation, + providerErrorCode: "provider_error", + }, + "remote secret import row failed", + ); + return fallback; +} + type CanonicalEnvBinding = | { type: "plain"; value: string } | { type: "secret_ref"; secretId: string; version: number | "latest" }; @@ -1038,12 +1111,22 @@ export function secretService(db: Db) { if (!provider.listRemoteSecrets) { throw unprocessable(`${providerId} provider does not support remote import listing`); } - const listed = await provider.listRemoteSecrets({ - providerConfig: runtimeConfig, - query: input.query, - nextToken: input.nextToken, - pageSize: input.pageSize, - }); + let listed: RemoteSecretListResult; + try { + listed = await provider.listRemoteSecrets({ + providerConfig: runtimeConfig, + query: input.query, + nextToken: input.nextToken, + pageSize: input.pageSize, + }); + } catch (error) { + throw remoteProviderHttpError(error, { + companyId, + provider: providerId, + providerConfigId: providerConfig.id, + operation: "remote_import.preview", + }); + } const maps = await buildRemoteImportConflictMaps(companyId, providerId); const candidates: RemoteSecretImportCandidate[] = []; for (const remote of listed.secrets) { @@ -1069,7 +1152,12 @@ export function secretService(db: Db) { } catch (error) { conflicts.push({ type: "provider_guardrail", - message: error instanceof Error ? error.message : "Provider rejected this external reference", + message: remoteImportRowFailureReason(error, "Provider rejected this external reference", { + companyId, + provider: providerId, + providerConfigId: providerConfig.id, + operation: "remote_import.preview.link_external_reference", + }), }); } conflicts.push(...remoteImportConflictsFor({ @@ -1179,7 +1267,12 @@ export function secretService(db: Db) { name, key, status: "error", - reason: error instanceof Error ? error.message : "Provider rejected this external reference", + reason: remoteImportRowFailureReason(error, "Provider rejected this external reference", { + companyId, + provider: providerId, + providerConfigId: providerConfig.id, + operation: "remote_import.prepare_external_reference", + }), secretId: null, conflicts: [], }); @@ -1274,7 +1367,12 @@ export function secretService(db: Db) { name, key, status: "error", - reason: error instanceof Error ? error.message : "Import failed", + reason: remoteImportRowFailureReason(error, "Import failed", { + companyId, + provider: providerId, + providerConfigId: providerConfig.id, + operation: "remote_import.commit", + }), secretId: null, conflicts: [], }); diff --git a/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx index d5f54f72473..3ff3a0a33f3 100644 --- a/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx +++ b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx @@ -11,6 +11,7 @@ import type { RemoteSecretImportResult, } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../api/client"; const mockSecretsApi = vi.hoisted(() => ({ remoteImportPreview: vi.fn(), @@ -412,7 +413,7 @@ describe("ImportFromVaultDialog", () => { name: "gamma", key: "gamma", status: "error", - reason: "AccessDeniedException for arn:aws:…/c", + reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", secretId: null, conflicts: [], }, @@ -473,7 +474,9 @@ describe("ImportFromVaultDialog", () => { expect(document.body.textContent).toContain("1 created"); expect(document.body.textContent).toContain("1 skipped"); expect(document.body.textContent).toContain("1 failed"); - expect(document.body.textContent).toContain("AccessDeniedException"); + expect(document.body.textContent).toContain("AWS Secrets Manager denied the request"); + expect(document.body.textContent).not.toContain("AccessDeniedException"); + expect(document.body.textContent).not.toContain("123456789012"); await act(async () => { root.unmount(); @@ -542,6 +545,48 @@ describe("ImportFromVaultDialog", () => { }); }); + it("renders sanitized preview provider errors without raw AWS exception text", async () => { + const rawProviderMessage = + "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized"; + mockSecretsApi.remoteImportPreview.mockRejectedValueOnce( + new ApiError( + "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + 403, + { error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", details: { code: "access_denied" } }, + ), + ); + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const banner = document.querySelector('[data-testid="preview-error-banner"]'); + expect(banner).not.toBeNull(); + expect(banner?.textContent).toContain("AWS denied list access"); + expect(banner?.textContent).toContain("missing secretsmanager:ListSecrets"); + expect(banner?.textContent).not.toContain(rawProviderMessage); + expect(banner?.textContent).not.toContain("arn:aws"); + expect(banner?.textContent).not.toContain("123456789012"); + + await act(async () => { + root.unmount(); + }); + }); + it("debounces search and uses the new query for the next preview", async () => { mockSecretsApi.remoteImportPreview .mockResolvedValueOnce(makePreview([makeCandidate()])) diff --git a/ui/src/pages/secrets/ImportFromVaultDialog.tsx b/ui/src/pages/secrets/ImportFromVaultDialog.tsx index e9fe67f3c13..88f831d6a65 100644 --- a/ui/src/pages/secrets/ImportFromVaultDialog.tsx +++ b/ui/src/pages/secrets/ImportFromVaultDialog.tsx @@ -201,8 +201,22 @@ function readableErrorMessage(error: unknown): string { return "Unexpected error"; } +function apiErrorCode(error: ApiError): string | null { + const body = error.body; + if (!body || typeof body !== "object") return null; + const record = body as Record; + if (typeof record.code === "string") return record.code; + const details = record.details; + if (details && typeof details === "object") { + const code = (details as Record).code; + if (typeof code === "string") return code; + } + return null; +} + function isPermissionError(error: unknown): boolean { if (!(error instanceof ApiError)) return false; + if (apiErrorCode(error) === "access_denied") return true; if (error.status === 401 || error.status === 403) return true; const message = error.message.toLowerCase(); return ( @@ -214,6 +228,7 @@ function isPermissionError(error: unknown): boolean { function isThrottlingError(error: unknown): boolean { if (!(error instanceof ApiError)) return false; + if (apiErrorCode(error) === "throttled") return true; const message = error.message.toLowerCase(); return message.includes("throttl") || message.includes("toomanyrequests"); } @@ -1458,4 +1473,3 @@ function FooterStatus({ } return null; } -