Merge pull request #516 from useautumn/feat/attach-idempotency
feat: 🎸 idempotency
This commit is contained in:
8
server/src/external/autumn/autumnCli.ts
vendored
8
server/src/external/autumn/autumnCli.ts
vendored
@@ -126,10 +126,10 @@ export class AutumnInt {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async post(path: string, body: any) {
|
||||
async post(path: string, body: any, headers?: Record<string, string>) {
|
||||
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<string, string>) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
36
server/src/honoMiddlewares/idempotencyMiddleware.ts
Normal file
36
server/src/honoMiddlewares/idempotencyMiddleware.ts
Normal file
@@ -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<HonoEnv>,
|
||||
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();
|
||||
};
|
||||
@@ -43,6 +43,8 @@ const ALLOWED_HEADERS = [
|
||||
"If-None-Match",
|
||||
"If-Modified-Since",
|
||||
"If-Unmodified-Since",
|
||||
"idempotency-key",
|
||||
"Idempotency-Key",
|
||||
];
|
||||
|
||||
export const createHonoApp = () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
93
server/tests/attach/others/others10.test.ts
Normal file
93
server/tests/attach/others/others10.test.ts
Normal file
@@ -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<ReturnType<typeof autumnV1.attach>>
|
||||
>[];
|
||||
|
||||
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<ReturnType<typeof autumnV1.attach>>
|
||||
>;
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user