fix: harden client rate-limit error handling

This commit is contained in:
Bhagya Amarasinghe
2026-05-15 01:40:39 +05:30
parent 6c5380a9aa
commit e1d1963b4c
5 changed files with 122 additions and 19 deletions
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TooManyRequestsError } from "@formbricks/types/errors";
const mocks = vi.hoisted(() => ({
applyClientRateLimit: vi.fn(),
@@ -66,7 +67,9 @@ describe("api/v2 client displays route", () => {
});
test("returns 429 before processing when rate limiting rejects", async () => {
mocks.applyClientRateLimit.mockRejectedValue(new Error("Rate limit exceeded"));
mocks.applyClientRateLimit.mockRejectedValue(
new TooManyRequestsError("Maximum number of requests reached. Please try again later.")
);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
@@ -86,7 +89,7 @@ describe("api/v2 client displays route", () => {
expect(response.status).toBe(429);
expect(await response.json()).toEqual({
code: "too_many_requests",
message: "Rate limit exceeded",
message: "Maximum number of requests reached. Please try again later.",
details: {},
});
expect(mocks.applyClientRateLimit).toHaveBeenCalledWith(environmentId);
@@ -94,6 +97,39 @@ describe("api/v2 client displays route", () => {
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected rate limit failures with a generic public response", async () => {
const underlyingError = new Error("redis connection failed");
mocks.applyClientRateLimit.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
@@ -1,4 +1,4 @@
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError, ResourceNotFoundError, TooManyRequestsError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
@@ -50,21 +50,33 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const applyRateLimit = async (environmentId: string): Promise<Response | null> => {
const rateLimitMessage = "Maximum number of requests reached. Please try again later.";
const applyRateLimit = async (request: Request, environmentId: string): Promise<Response | null> => {
try {
await applyClientRateLimit(environmentId);
return null;
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
);
if (
error instanceof TooManyRequestsError ||
(error instanceof Error && error.name === "TooManyRequestsError")
) {
return responses.tooManyRequestsResponse(rateLimitMessage, true);
}
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
status: response.status,
error,
});
return response;
}
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const rateLimitResponse = await applyRateLimit(params.environmentId);
const rateLimitResponse = await applyRateLimit(request, params.environmentId);
if (rateLimitResponse) {
return rateLimitResponse;
}
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TooManyRequestsError } from "@formbricks/types/errors";
const mocks = vi.hoisted(() => ({
applyClientRateLimit: vi.fn(),
@@ -76,7 +77,9 @@ describe("api/v2 client responses route", () => {
});
test("returns 429 before processing when rate limiting rejects", async () => {
mocks.applyClientRateLimit.mockRejectedValue(new Error("Rate limit exceeded"));
mocks.applyClientRateLimit.mockRejectedValue(
new TooManyRequestsError("Maximum number of requests reached. Please try again later.")
);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
@@ -98,7 +101,7 @@ describe("api/v2 client responses route", () => {
expect(response.status).toBe(429);
expect(await response.json()).toEqual({
code: "too_many_requests",
message: "Rate limit exceeded",
message: "Maximum number of requests reached. Please try again later.",
details: {},
});
expect(mocks.applyClientRateLimit).toHaveBeenCalledWith(environmentId);
@@ -107,6 +110,44 @@ describe("api/v2 client responses route", () => {
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected rate limit failures with the same generic public response", async () => {
const underlyingError = new Error("redis connection failed");
mocks.applyClientRateLimit.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-rate-limit",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.getSurvey).not.toHaveBeenCalled();
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
@@ -1,6 +1,6 @@
import { UAParser } from "ua-parser-js";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { InvalidInputError, TooManyRequestsError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -202,21 +202,33 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const applyRateLimit = async (environmentId: string): Promise<Response | null> => {
const rateLimitMessage = "Maximum number of requests reached. Please try again later.";
const applyRateLimit = async (request: Request, environmentId: string): Promise<Response | null> => {
try {
await applyClientRateLimit(environmentId);
return null;
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
);
if (
error instanceof TooManyRequestsError ||
(error instanceof Error && error.name === "TooManyRequestsError")
) {
return responses.tooManyRequestsResponse(rateLimitMessage, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
status: response.status,
error,
});
return response;
}
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const rateLimitResponse = await applyRateLimit(params.environmentId);
const rateLimitResponse = await applyRateLimit(request, params.environmentId);
if (rateLimitResponse) {
return rateLimitResponse;
}
+3 -1
View File
@@ -59,8 +59,10 @@ enum ApiV1RouteTypeEnum {
Integration = "integration",
}
const clientEnvironmentPathRegex = /^\/api\/v\d+\/client\/([^/]+)/;
const getClientEnvironmentIdFromPathname = (pathname: string): string | null => {
const match = pathname.match(/^\/api\/v\d+\/client\/([^/]+)/);
const match = clientEnvironmentPathRegex.exec(pathname);
return match?.[1] ?? null;
};