Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add rate limiting and more error handling to Cal.ai #11898

Merged
merged 13 commits into from
Oct 17, 2023
2 changes: 1 addition & 1 deletion apps/ai/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
Expand Down
7 changes: 7 additions & 0 deletions apps/ai/src/app/api/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export const POST = async (request: NextRequest) => {

return new NextResponse("ok");
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
to: user.email,
from: agentEmail,
});

return new NextResponse(
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
{ status: 500 }
Expand Down
43 changes: 33 additions & 10 deletions apps/ai/src/app/api/receive/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { simpleParser } from "mailparser";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";

import { env } from "../../../env.mjs";
Expand Down Expand Up @@ -31,18 +32,37 @@ export const POST = async (request: NextRequest) => {

const formData = await request.formData();
const body = Object.fromEntries(formData);

// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");

const envelope = JSON.parse(body.envelope as string);

const aiEmail = envelope.to[0];
const subject = body.subject || "";

try {
await checkRateLimitAndThrowError({
identifier: `ai:email:${envelope.from}`,
rateLimitingType: "ai",
});
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
to: envelope.from,
from: aiEmail,
});

return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
}

// Parse email from mixed MIME type
const parsed: ParsedMail = await simpleParser(body.email as Source);

if (!parsed.text && !parsed.subject) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Email missing text and subject", { status: 400 });
}

Expand All @@ -62,11 +82,14 @@ export const POST = async (request: NextRequest) => {
where: { email: envelope.from },
});

// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");

// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
from: aiEmail,
Expand All @@ -83,7 +106,7 @@ export const POST = async (request: NextRequest) => {

await sendEmail({
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
to: envelope.from,
from: aiEmail,
Expand All @@ -110,7 +133,7 @@ export const POST = async (request: NextRequest) => {

if ("error" in availability) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your availability. Please try again.",
to: user.email,
from: aiEmail,
Expand All @@ -121,7 +144,7 @@ export const POST = async (request: NextRequest) => {

if ("error" in eventTypes) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your event types. Please try again.",
to: user.email,
from: aiEmail,
Expand All @@ -139,8 +162,8 @@ export const POST = async (request: NextRequest) => {
body: JSON.stringify({
apiKey,
userId: user.id,
message: parsed.text,
subject: parsed.subject,
message: parsed.text || "",
subject: parsed.subject || "",
replyTo: aiEmail,
user: {
email: user.email,
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/checkRateLimitAndThrowError.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TRPCError } from "@calcom/trpc";
import { TRPCError } from "@calcom/trpc/server";
Copy link
Contributor Author

@DexterStorey DexterStorey Oct 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!!!IMPORTANT!!!

We needed to swap this TRPC import from client side to server side because the AI app uses it server side.
Rate limiting in our use case Does not work without this.

We need a senior engineer on Cal team to verify that this change is okay.

We don't know if this has upstream repercussions or if it's no prob.

Please don't merge this without checking this out. 🙏

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zomars @emrysal @PeerRich

Flagging this, otherwise, this PR should be good to 🚀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks safe to me <3

This is only ever ran on the server in all of use cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this looks like an ideal change actually


import type { RateLimitHelper } from "./rateLimit";
import { rateLimiter } from "./rateLimit";
Expand Down
8 changes: 7 additions & 1 deletion packages/lib/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import logger from "./logger";
const log = logger.getChildLogger({ prefix: ["RateLimit"] });

export type RateLimitHelper = {
rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api";
rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api" | "ai";
identifier: string;
};

Expand Down Expand Up @@ -75,6 +75,12 @@ export function rateLimiter() {
prefix: "ratelimit:api",
limiter: Ratelimit.fixedWindow(10, "60s"),
}),
ai: new Ratelimit({
redis,
analytics: true,
prefix: "ratelimit",
limiter: Ratelimit.fixedWindow(20, "1d"),
}),
};

async function rateLimit({ rateLimitingType = "core", identifier }: RateLimitHelper) {
Expand Down