diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 10f939954..de7c9b124 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -126,10 +126,10 @@ export class AutumnInt { return response.json(); } - async post(path: string, body: any) { + async post(path: string, body: any, headers?: Record) { const response = await fetch(`${this.baseUrl}${path}`, { method: "POST", - headers: this.headers, + headers: { ...this.headers, ...headers }, body: JSON.stringify(body), }); @@ -252,13 +252,13 @@ export class AutumnInt { return data; } - async attach(params: AttachBodyV0) { + async attach(params: AttachBodyV0, headers?: Record) { // const data = await this.post(`/attach`, { // customer_id: customerId, // product_id: productId, // options: toSnakeCase(options), // }); - const data = await this.post(`/attach`, params); + const data = await this.post(`/attach`, params, headers); return data; } diff --git a/server/src/honoMiddlewares/idempotencyMiddleware.ts b/server/src/honoMiddlewares/idempotencyMiddleware.ts new file mode 100644 index 000000000..f7cbea336 --- /dev/null +++ b/server/src/honoMiddlewares/idempotencyMiddleware.ts @@ -0,0 +1,36 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; +import type { Context, Next } from "hono"; +import { redis } from "@/external/redis/initRedis.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils"; + +/** + * Middleware that checks for idempotence in a request + */ +export const idempotencyMiddleware = async ( + c: Context, + next: Next, +) => { + const headers = c.req.header(); + const ctx = c.get("ctx"); + const idempotencyKey = + headers["idempotency-key"] || headers["Idempotency-Key"]; + + if (idempotencyKey) { + const redisKey = `${ctx.org.id}:${ctx.env}:idempotency:${idempotencyKey}`; + // Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions + const wasSet = await tryRedisWrite(() => { + return redis.set(redisKey, "1", "PX", 1000 * 60 * 60 * 24, "NX"); // 24 hours, only set if not exists + }); + + if (!wasSet) { + throw new RecaseError({ + message: `Another request with idempotency key ${idempotencyKey} has already been received`, + code: ErrCode.DuplicateIdempotencyKey, + statusCode: 409, + }); + } + } + + await next(); +}; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index c43160d57..9a5f00d67 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -43,6 +43,8 @@ const ALLOWED_HEADERS = [ "If-None-Match", "If-Modified-Since", "If-Unmodified-Since", + "idempotency-key", + "Idempotency-Key", ]; export const createHonoApp = () => { diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index ec1aeeae3..3d0bf82f3 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -1,26 +1,27 @@ import { Hono } from "hono"; -import { insightsRouter } from "@/internal/analytics/insightsRouter.js"; -import { legacyAnalyticsRouter } from "@/internal/analytics/legacyAnalyticsRouter.js"; -import { eventsRouter } from "@/internal/events/eventsRouter.js"; -import { analyticsMiddleware } from "../honoMiddlewares/analyticsMiddleware.js"; -import { apiVersionMiddleware } from "../honoMiddlewares/apiVersionMiddleware.js"; -import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware.js"; -import { queryMiddleware } from "../honoMiddlewares/queryMiddleware.js"; -import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js"; -import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js"; -import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware.js"; -import type { HonoEnv } from "../honoUtils/HonoEnv.js"; +import { insightsRouter } from "@/internal/analytics/insightsRouter"; +import { legacyAnalyticsRouter } from "@/internal/analytics/legacyAnalyticsRouter"; +import { eventsRouter } from "@/internal/events/eventsRouter"; +import { analyticsMiddleware } from "../honoMiddlewares/analyticsMiddleware"; +import { apiVersionMiddleware } from "../honoMiddlewares/apiVersionMiddleware"; +import { idempotencyMiddleware } from "../honoMiddlewares/idempotencyMiddleware"; +import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware"; +import { queryMiddleware } from "../honoMiddlewares/queryMiddleware"; +import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware"; +import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware"; +import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware"; +import type { HonoEnv } from "../honoUtils/HonoEnv"; import { redemptionRouter, referralRouter, -} from "../internal/api/rewards/referralRouter.js"; -import { balancesRouter } from "../internal/balances/balancesRouter.js"; -import { billingRouter } from "../internal/billing/billingRouter.js"; -import { cusRouter } from "../internal/customers/cusRouter.js"; -import { entityRouter } from "../internal/entities/entityRouter.js"; -import { featureRouter } from "../internal/features/featureRouter.js"; -import { honoOrgRouter } from "../internal/orgs/orgRouter.js"; -import { platformBetaRouter } from "../internal/platform/platformBeta/platformBetaRouter.js"; +} from "../internal/api/rewards/referralRouter"; +import { balancesRouter } from "../internal/balances/balancesRouter"; +import { billingRouter } from "../internal/billing/billingRouter"; +import { cusRouter } from "../internal/customers/cusRouter"; +import { entityRouter } from "../internal/entities/entityRouter"; +import { featureRouter } from "../internal/features/featureRouter"; +import { honoOrgRouter } from "../internal/orgs/orgRouter"; +import { platformBetaRouter } from "../internal/platform/platformBeta/platformBetaRouter"; import { honoProductBetaRouter, honoProductRouter, @@ -36,6 +37,7 @@ apiRouter.use("*", analyticsMiddleware); apiRouter.use("*", rateLimitMiddleware); apiRouter.use("*", refreshCacheMiddleware); apiRouter.use("*", queryMiddleware()); +apiRouter.use("*", idempotencyMiddleware); apiRouter.route("", billingRouter); apiRouter.route("", balancesRouter); diff --git a/server/tests/attach/others/others10.test.ts b/server/tests/attach/others/others10.test.ts new file mode 100644 index 000000000..e3cd68355 --- /dev/null +++ b/server/tests/attach/others/others10.test.ts @@ -0,0 +1,93 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, ErrCode } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { generateId } from "@/utils/genUtils"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const pro = constructProduct({ + type: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 500, + }), + ], +}); + +const testCase = "others10"; + +describe(`${chalk.yellowBright(`${testCase}/idempotency: idempotency key already exists`)}`, () => { + const customerId = testCase; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const idempotencyKey = generateId("it"); + + let results: PromiseSettledResult< + Awaited> + >[]; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [pro], + prefix: testCase, + }); + + results = await Promise.allSettled([ + autumnV1.attach( + { + customer_id: customerId, + product_id: pro.id, + }, + { + "idempotency-key": idempotencyKey, + }, + ), + autumnV1.attach( + { + customer_id: customerId, + product_id: pro.id, + }, + { + "idempotency-key": idempotencyKey, + }, + ), + ]); + }); + + test("should reject duplicate idempotency key with 409", async () => { + // Exactly one request should succeed + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter((r) => r.status === "rejected"); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + + // The successful request should have attached the product + const successResult = fulfilled[0] as PromiseFulfilledResult< + Awaited> + >; + expect(successResult.value.success).toBe(true); + expect(successResult.value.customer_id).toBe(customerId); + expect(successResult.value.product_ids).toContain(pro.id); + + // The rejected request should have the duplicate idempotency key error + const rejectedResult = rejected[0] as PromiseRejectedResult; + expect(rejectedResult.reason).toBeInstanceOf(AutumnError); + expect((rejectedResult.reason as AutumnError).code).toBe( + ErrCode.DuplicateIdempotencyKey, + ); + }); +}); diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 34878ab26..c11d014b8 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -2,6 +2,7 @@ export const ErrCode = { // Idempotency IdempotencyKeyAlreadyExists: "idempotency_key_already_exists", IdempotencyKeyNotFound: "idempotency_key_not_found", + DuplicateIdempotencyKey: "duplicate_idempotency_key", // Auth InvalidApiVersion: "invalid_api_version",