diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 7656826eb..3ff7ac17e 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -51,7 +51,9 @@ const ignoredEvents = [ "balance.available", "customer.updated", "customer.created", + "invoice_payment.paid", "payout.created", + "payout.paid", "payout.reconciliation_completed", ] as const satisfies Stripe.Event.Type[]; diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 424c66537..fca4a7936 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -141,6 +141,19 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption } as const); } + // nodemailer surfaces a refused connection as code 'ESOCKET' with 'ECONNREFUSED' in the message. + // Safe to retry: the connection was refused before any SMTP exchange, so the message was never + // handed off — there's no duplicate-delivery risk, and a transient refusal (server restarting / + // overloaded) can recover. A persistent misconfig still fails after MAX_SEND_ATTEMPTS. + if (code === 'ECONNREFUSED' || error.message.includes('ECONNREFUSED')) { + return Result.error({ + rawError: error, + errorType: 'CONNECTION_REFUSED', + canRetry: true, + message: 'The email server refused the connection. Please make sure the email host and port configuration are correct.', + } as const); + } + if (responseCode === 535 || code === 'EAUTH') { return Result.error({ rawError: error, diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index 80bad1ff1..fc9c6c7b4 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -110,6 +110,9 @@ const MAX_APPROX_BYTES_PER_BATCH = 512_000; // envelope overhead (browser_session_id, timestamps, wrapper keys, etc.). const MAX_FLUSH_PAYLOAD_BYTES = 900_000; +// Reused across the emit hot path to avoid per-event allocation. +const textEncoder = new TextEncoder(); + export type StoredSession = { session_id: string, created_at_ms: number, @@ -286,6 +289,17 @@ export class SessionRecorder { // When _flushInProgress blocked earlier flushes, events can accumulate // well past MAX_APPROX_BYTES_PER_BATCH; sending them all at once would // exceed the server's 1MB body limit (413). + // A single event over the limit can't be sent (rrweb events aren't splittable); drop it and move on. + const firstSize = allSizes[offset] ?? throwErr("_eventSizes out of sync with _events — this should never happen"); + if (firstSize > MAX_FLUSH_PAYLOAD_BYTES) { + captureWarning( + "SessionRecorder.flush", + new Error(`Dropping oversized session replay event (${firstSize} bytes > ${MAX_FLUSH_PAYLOAD_BYTES} byte limit); it cannot be sent without a 413.`), + ); + offset += 1; + continue; + } + let batchBytes = 0; let batchEnd = offset; for (let i = offset; i < allEvents.length; i++) { @@ -384,7 +398,8 @@ export class SessionRecorder { } } - const eventSize = JSON.stringify(event).length; + // Measure UTF-8 byte length to match the server's byte limit (.length counts UTF-16 units, undercounting multibyte content). + const eventSize = textEncoder.encode(JSON.stringify(event)).byteLength; this._events.push(event); this._eventSizes.push(eventSize); this._approxBytes += eventSize;