mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-23 02:45:21 -05:00
fix: harden client rate-limit error handling
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user