Skip to content

Commit 75c0f14

Browse files
committed
feat: scope credit grant idempotency key to customer
The credit grant idempotency key was unique per (namespace, key), so two different customers in the same namespace could not reuse the same key even though grant creation and key lookup are both customer-scoped. Re-scope the partial unique index to (namespace, customer_id, key) and document the scope on the API field. Loosening the constraint cannot fail on existing data: any rows violating the new index would have violated the old one.
1 parent 8123cc6 commit 75c0f14

11 files changed

Lines changed: 793 additions & 765 deletions

File tree

api/spec/packages/aip-client-javascript/src/models/types.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3429,8 +3429,9 @@ export interface CreateCreditGrantRequest {
34293429
/**
34303430
* Idempotency key for the credit grant creation request.
34313431
*
3432-
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
3433-
* creating a duplicate grant, which makes create requests safe to retry.
3432+
* Unique per customer: reusing the same key for the same customer returns an HTTP
3433+
* 409 Conflict instead of creating a duplicate grant, which makes create requests
3434+
* safe to retry. The same key may be reused across different customers.
34343435
*/
34353436
key?: string
34363437
}
@@ -3493,8 +3494,9 @@ export interface CreditGrant {
34933494
/**
34943495
* Idempotency key for the credit grant creation request.
34953496
*
3496-
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
3497-
* creating a duplicate grant, which makes create requests safe to retry.
3497+
* Unique per customer: reusing the same key for the same customer returns an HTTP
3498+
* 409 Conflict instead of creating a duplicate grant, which makes create requests
3499+
* safe to retry. The same key may be reused across different customers.
34983500
*/
34993501
key?: string
35003502
/**
@@ -5944,8 +5946,9 @@ export interface CreateCreditGrantRequestInput {
59445946
/**
59455947
* Idempotency key for the credit grant creation request.
59465948
*
5947-
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
5948-
* creating a duplicate grant, which makes create requests safe to retry.
5949+
* Unique per customer: reusing the same key for the same customer returns an HTTP
5950+
* 409 Conflict instead of creating a duplicate grant, which makes create requests
5951+
* safe to retry. The same key may be reused across different customers.
59495952
*/
59505953
key?: string
59515954
}
@@ -6008,8 +6011,9 @@ export interface CreditGrantInput {
60086011
/**
60096012
* Idempotency key for the credit grant creation request.
60106013
*
6011-
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
6012-
* creating a duplicate grant, which makes create requests safe to retry.
6014+
* Unique per customer: reusing the same key for the same customer returns an HTTP
6015+
* 409 Conflict instead of creating a duplicate grant, which makes create requests
6016+
* safe to retry. The same key may be reused across different customers.
60136017
*/
60146018
key?: string
60156019
/**

api/spec/packages/aip/src/customers/credits/grant.tsp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,9 @@ model CreditGrant {
209209
/**
210210
* Idempotency key for the credit grant creation request.
211211
*
212-
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
213-
* creating a duplicate grant, which makes create requests safe to retry.
212+
* Unique per customer: reusing the same key for the same customer returns an HTTP
213+
* 409 Conflict instead of creating a duplicate grant, which makes create requests
214+
* safe to retry. The same key may be reused across different customers.
214215
*/
215216
@visibility(Lifecycle.Read, Lifecycle.Create)
216217
key?: Shared.ExternalResourceKey;

api/v3/api.gen.go

Lines changed: 737 additions & 734 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v3/client/models_customers.go

Lines changed: 6 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v3/openapi.yaml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5440,8 +5440,9 @@ components:
54405440
description: |-
54415441
Idempotency key for the credit grant creation request.
54425442
5443-
When provided, reusing the same key returns an HTTP 409 Conflict instead of
5444-
creating a duplicate grant, which makes create requests safe to retry.
5443+
Unique per customer: reusing the same key for the same customer returns an HTTP
5444+
409 Conflict instead of creating a duplicate grant, which makes create requests
5445+
safe to retry. The same key may be reused across different customers.
54455446
expires_at:
54465447
allOf:
54475448
- $ref: '#/components/schemas/DateTime'
@@ -9432,8 +9433,9 @@ components:
94329433
description: |-
94339434
Idempotency key for the credit grant creation request.
94349435
9435-
When provided, reusing the same key returns an HTTP 409 Conflict instead of
9436-
creating a duplicate grant, which makes create requests safe to retry.
9436+
Unique per customer: reusing the same key for the same customer returns an HTTP
9437+
409 Conflict instead of creating a duplicate grant, which makes create requests
9438+
safe to retry. The same key may be reused across different customers.
94379439
additionalProperties: false
94389440
description: CreditGrant create request.
94399441
CreateCreditGrantTaxConfig:

e2e/customer_credits_v3_test.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,18 @@ func TestV3CreateCreditGrantIdempotencyKey(t *testing.T) {
134134
c.requireStatus(http.StatusCreated, err)
135135
})
136136

137-
t.Run("the same key conflicts across different customers in a namespace", func(t *testing.T) {
137+
t.Run("the same key is allowed across different customers", func(t *testing.T) {
138138
key := ulid.Make().String()
139139

140-
_, err := c.Customers.Credits.Grants.Create(t.Context(), createCustomer("credit_grant_idem_cust_a"), grant(&key))
140+
grantA, err := c.Customers.Credits.Grants.Create(t.Context(), createCustomer("credit_grant_idem_cust_a"), grant(&key))
141141
c.requireStatus(http.StatusCreated, err)
142+
require.NotNil(t, grantA)
142143

143-
_, err = c.Customers.Credits.Grants.Create(t.Context(), createCustomer("credit_grant_idem_cust_b"), grant(&key))
144-
requireProblem(t, err, http.StatusConflict)
144+
grantB, err := c.Customers.Credits.Grants.Create(t.Context(), createCustomer("credit_grant_idem_cust_b"), grant(&key))
145+
c.requireStatus(http.StatusCreated, err)
146+
require.NotNil(t, grantB)
147+
148+
require.NotEqual(t, grantA.ID, grantB.ID)
145149
})
146150

147151
t.Run("an over-length key is rejected with 400", func(t *testing.T) {
@@ -331,8 +335,8 @@ func TestV3VoidCreditGrantPaymentAdjustmentNone(t *testing.T) {
331335

332336
// TestV3CreditGrantKeyReadAndFilter verifies that the idempotency key is exposed
333337
// on the get/list read responses and that list credit grants can be filtered by
334-
// key. The key column carries a unique partial index per namespace, so an
335-
// equality filter targets at most one grant.
338+
// key. The key column carries a unique partial index per namespace and customer,
339+
// so a customer-scoped equality filter targets at most one grant.
336340
func TestV3CreditGrantKeyReadAndFilter(t *testing.T) {
337341
c := newV3Client(t)
338342
currency := v3sdk.BillingCurrencyCode("USD")

openmeter/ent/db/migrate/schema.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openmeter/ent/schema/chargescreditpurchase.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,10 @@ func (ChargeCreditPurchase) Indexes() []ent.Index {
125125
return []ent.Index{
126126
index.Fields("tax_code_id").
127127
StorageKey("chargecreditpurchases_tax_code_id"),
128-
// Idempotency key, unique per namespace. Partial so it is enforced only while live:
129-
// NULL means no idempotency requested, and a soft-deleted grant must not permanently
130-
// reserve a key the caller may reuse.
131-
index.Fields("namespace", "key").
128+
// Idempotency key, unique per customer within a namespace. Partial so it is enforced
129+
// only while live: NULL means no idempotency requested, and a soft-deleted grant must
130+
// not permanently reserve a key the caller may reuse.
131+
index.Fields("namespace", "customer_id", "key").
132132
Annotations(
133133
entsql.IndexWhere("key IS NOT NULL AND deleted_at IS NULL"),
134134
).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- reverse: create index "chargecreditpurchase_namespace_customer_id_key" to table: "charge_credit_purchases"
2+
DROP INDEX "chargecreditpurchase_namespace_customer_id_key";
3+
-- reverse: drop index "chargecreditpurchase_namespace_key" from table: "charge_credit_purchases"
4+
CREATE UNIQUE INDEX "chargecreditpurchase_namespace_key" ON "charge_credit_purchases" ("namespace", "key") WHERE ((key IS NOT NULL) AND (deleted_at IS NULL));
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- drop index "chargecreditpurchase_namespace_key" from table: "charge_credit_purchases"
2+
DROP INDEX "chargecreditpurchase_namespace_key";
3+
-- create index "chargecreditpurchase_namespace_customer_id_key" to table: "charge_credit_purchases"
4+
-- the old (namespace, key) unique index guarantees no (namespace, customer_id, key) duplicates exist,
5+
-- so widening the key scope cannot fail on existing data
6+
-- atlas:nolint MF101
7+
CREATE UNIQUE INDEX "chargecreditpurchase_namespace_customer_id_key" ON "charge_credit_purchases" ("namespace", "customer_id", "key") WHERE ((key IS NOT NULL) AND (deleted_at IS NULL));

0 commit comments

Comments
 (0)