feat: 🎸 idempotency on attach
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;
|
||||
}
|
||||
|
||||
39
server/src/honoMiddlewares/idempotencyMiddleware.ts
Normal file
39
server/src/honoMiddlewares/idempotencyMiddleware.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
ErrCode
|
||||
} 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"];
|
||||
|
||||
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) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Another request with idempotency key ${idempotencyKey} has already been received`,
|
||||
code: ErrCode.DuplicateIdempotencyKey,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { idempotencyMiddleware } from "@/honoMiddlewares/idempotencyMiddleware.js";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||
import { handleAttachV2 } from "./attach/handleAttachV2.js";
|
||||
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
|
||||
@@ -6,6 +7,8 @@ import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
|
||||
|
||||
export const billingRouter = new Hono<HonoEnv>();
|
||||
|
||||
billingRouter.use("*", idempotencyMiddleware);
|
||||
|
||||
billingRouter.post("/setup_payment", ...handleSetupPayment);
|
||||
billingRouter.post("/checkout", ...handleCheckoutV2);
|
||||
billingRouter.post("/attach", ...handleAttachV2);
|
||||
|
||||
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("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