diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts index 884d3eaeab0..aa5c976d880 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; +import { BadGatewayException, BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; import { CacheService, decryptCredentials, MailFactory, PinoLogger } from '@novu/application-generic'; import { IntegrationRepository } from '@novu/dal'; import type { SentMessageInfo } from '@novu/framework'; @@ -14,6 +14,16 @@ import { sendWebResponse, toWebRequest } from '../utils/express-to-web-request'; import { AgentConfigResolver, ResolvedAgentConfig } from './agent-config-resolver.service'; import { AgentInboundHandler } from './agent-inbound-handler.service'; +function toDeliveryError(err: unknown): never { + const base = err instanceof Error ? err.message : String(err); + const body = (err as any)?.response?.body; + const detail = Array.isArray(body?.errors) ? body.errors[0]?.message : body?.message; + throw new BadGatewayException({ + error: 'delivery_failed', + message: detail ? `${base}: ${detail}` : base, + }); +} + /** Ensure a Message-ID value is wrapped in RFC 5322 angle brackets. */ function wrapMsgId(id: string): string { const trimmed = id.trim(); @@ -134,15 +144,17 @@ export class ChatSdkService implements OnModuleDestroy { const adapter = chat.getAdapter(platform); const thread = ThreadImpl.fromJSON(serializedThread, adapter); - let sent: { id: string; threadId: string }; + let postPromise: Promise<{ id: string; threadId: string }>; if (content.card) { - sent = await thread.post(content.card); + postPromise = thread.post(content.card); } else if (content.markdown !== undefined) { - sent = await thread.post({ markdown: content.markdown, files: content.files }); + postPromise = thread.post({ markdown: content.markdown, files: content.files }); } else { - sent = await thread.post(content.text ?? ''); + postPromise = thread.post(content.text ?? ''); } + const sent = await postPromise.catch(toDeliveryError); + return { messageId: sent.id, platformThreadId: sent.threadId }; } @@ -163,22 +175,24 @@ export class ChatSdkService implements OnModuleDestroy { throw new BadRequestException(`Platform ${platform} does not support editing messages`); } - let edited: { id: string; threadId: string }; + let editPromise: Promise<{ id: string; threadId: string }>; if (content.card) { - edited = await adapter.editMessage( + editPromise = adapter.editMessage( platformThreadId, platformMessageId, content.card as unknown as AdapterPostableMessage ); } else if (content.markdown !== undefined) { - edited = await adapter.editMessage(platformThreadId, platformMessageId, { + editPromise = adapter.editMessage(platformThreadId, platformMessageId, { markdown: content.markdown, files: content.files, } as unknown as AdapterPostableMessage); } else { - edited = await adapter.editMessage(platformThreadId, platformMessageId, content.text ?? ''); + editPromise = adapter.editMessage(platformThreadId, platformMessageId, content.text ?? ''); } + const edited = await editPromise.catch(toDeliveryError); + return { messageId: edited.id, platformThreadId: edited.threadId }; } @@ -419,7 +433,7 @@ export class ChatSdkService implements OnModuleDestroy { }, }; - const result = await handler.send(mailOptions); + const result = await handler.send(mailOptions).catch(toDeliveryError); return { messageId: result?.id || params.messageId || '' }; }; diff --git a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts b/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts index 49b6878928c..06e02d9c80a 100644 --- a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts +++ b/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadGatewayException, BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { decryptCredentials, InstrumentUsecase, MailFactory } from '@novu/application-generic'; import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { ChannelTypeEnum, EmailProviderIdEnum, IEmailOptions } from '@novu/shared'; @@ -85,7 +85,15 @@ export class SendAgentTestEmail { senderName: (senderIntegration.credentials?.senderName as string) || 'Novu', }; - await handler.send(mailOptions); + await handler.send(mailOptions).catch((err) => { + const base = err instanceof Error ? err.message : String(err); + const body = (err as any)?.response?.body; + const detail = Array.isArray(body?.errors) ? body.errors[0]?.message : body?.message; + throw new BadGatewayException({ + error: 'delivery_failed', + message: detail ? `${base}: ${detail}` : base, + }); + }); return { success: true }; } diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index d584f80910e..fc76897d05b 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -30,6 +30,7 @@ export type { } from './resources'; export { Actions, + AgentDeliveryError, AgentEventEnum, agent, Button, diff --git a/packages/framework/src/resources/agent/agent.context.ts b/packages/framework/src/resources/agent/agent.context.ts index d40d25533e1..deb261c78c5 100644 --- a/packages/framework/src/resources/agent/agent.context.ts +++ b/packages/framework/src/resources/agent/agent.context.ts @@ -1,4 +1,5 @@ import { isJSX, toCardElement } from 'chat/jsx-runtime'; +import { AgentDeliveryError } from './agent.errors'; import type { AgentAction, AgentBridgeRequest, @@ -216,6 +217,18 @@ export class AgentContextImpl implements AgentContext { if (!response.ok) { const text = await response.text().catch(() => ''); + + if (response.status === 502) { + let message = text; + try { + const parsed = JSON.parse(text) as { message?: string }; + if (parsed.message) message = parsed.message; + } catch { + // use raw text if JSON parsing fails + } + throw new AgentDeliveryError(502, message); + } + throw new Error(`Agent reply failed (${response.status}): ${text}`); } diff --git a/packages/framework/src/resources/agent/agent.errors.ts b/packages/framework/src/resources/agent/agent.errors.ts new file mode 100644 index 00000000000..349955594b1 --- /dev/null +++ b/packages/framework/src/resources/agent/agent.errors.ts @@ -0,0 +1,32 @@ +/** + * Thrown by `ctx.reply()` and `handle.edit()` when the upstream message delivery + * fails — e.g. the configured email provider returns 401, Slack rejects the token, + * or Teams rejects the request. + * + * The `message` property contains the original provider error text + * + * @example + * ```ts + * import { AgentDeliveryError } from '@novu/framework'; + * + * try { + * await ctx.reply('Hello!'); + * } catch (err) { + * if (err instanceof AgentDeliveryError) { + * // Delivery failed (misconfigured provider, rate limit, etc.) + * console.error('Delivery failed:', err.message); + * return; + * } + * throw err; + * } + * ``` + */ +export class AgentDeliveryError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.name = 'AgentDeliveryError'; + this.statusCode = statusCode; + } +} diff --git a/packages/framework/src/resources/agent/index.ts b/packages/framework/src/resources/agent/index.ts index 7fd2a81445f..3b6edfbc3b4 100644 --- a/packages/framework/src/resources/agent/index.ts +++ b/packages/framework/src/resources/agent/index.ts @@ -12,6 +12,7 @@ export type { } from 'chat'; export { Actions, Button, Card, CardLink, CardText, Divider, Select, SelectOption, TextInput } from 'chat'; export { AgentContextImpl } from './agent.context'; +export { AgentDeliveryError } from './agent.errors'; export { agent } from './agent.resource'; export type { Agent,