Merge pull request #516 from useautumn/feat/attach-idempotency

feat: 🎸 idempotency
This commit is contained in:
John Yeo
2026-01-05 13:39:23 +00:00
committed by GitHub
6 changed files with 157 additions and 23 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,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();
};

View File

@@ -43,6 +43,8 @@ const ALLOWED_HEADERS = [
"If-None-Match",
"If-Modified-Since",
"If-Unmodified-Since",
"idempotency-key",
"Idempotency-Key",
];
export const createHonoApp = () => {

View File

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

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(`${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,
);
});
});

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