feat: 🎸 idempotency on attach

This commit is contained in:
amianthus
2026-01-05 12:07:18 +00:00
parent 37ab5a873d
commit e8963bf8e6
5 changed files with 140 additions and 4 deletions

View File

@@ -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;
}

View 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();
};

View File

@@ -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);

View 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,
);
});
});

View File

@@ -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",