Skip to content

Commit 2d75c96

Browse files
committed
fix(api): add idempotency key to create credit grant
1 parent 1446650 commit 2d75c96

24 files changed

Lines changed: 714 additions & 372 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3969,6 +3969,7 @@ export const createCreditGrantRequest = z
39693969
'Draw-down priority of the grant. Lower values have higher priority.',
39703970
),
39713971
expires_after: iso8601Duration.optional(),
3972+
key: externalResourceKey.optional(),
39723973
})
39733974
.describe('CreditGrant create request.')
39743975

@@ -4011,6 +4012,7 @@ export const creditGrant = z
40114012
'Draw-down priority of the grant. Lower values have higher priority.',
40124013
),
40134014
expires_after: iso8601Duration.optional(),
4015+
key: externalResourceKey.optional(),
40144016
expires_at: dateTime.optional(),
40154017
voided_at: dateTime.optional(),
40164018
status: creditGrantStatus,

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3569,6 +3569,13 @@ export interface CreateCreditGrantRequest {
35693569
* Defaults to never expiring.
35703570
*/
35713571
expires_after?: string
3572+
/**
3573+
* Idempotency key for the credit grant creation request.
3574+
*
3575+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
3576+
* creating a duplicate grant, which makes create requests safe to retry.
3577+
*/
3578+
key?: string
35723579
}
35733580

35743581
/**
@@ -3626,6 +3633,13 @@ export interface CreditGrant {
36263633
* Defaults to never expiring.
36273634
*/
36283635
expires_after?: string
3636+
/**
3637+
* Idempotency key for the credit grant creation request.
3638+
*
3639+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
3640+
* creating a duplicate grant, which makes create requests safe to retry.
3641+
*/
3642+
key?: string
36293643
/**
36303644
* The timestamp when the credit grant expires.
36313645
*
@@ -5432,6 +5446,13 @@ export interface CreateCreditGrantRequestInput {
54325446
* Defaults to never expiring.
54335447
*/
54345448
expires_after?: string
5449+
/**
5450+
* Idempotency key for the credit grant creation request.
5451+
*
5452+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
5453+
* creating a duplicate grant, which makes create requests safe to retry.
5454+
*/
5455+
key?: string
54355456
}
54365457

54375458
export interface CreditGrantInput {
@@ -5483,6 +5504,13 @@ export interface CreditGrantInput {
54835504
* Defaults to never expiring.
54845505
*/
54855506
expires_after?: string
5507+
/**
5508+
* Idempotency key for the credit grant creation request.
5509+
*
5510+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
5511+
* creating a duplicate grant, which makes create requests safe to retry.
5512+
*/
5513+
key?: string
54865514
/**
54875515
* The timestamp when the credit grant expires.
54885516
*

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ model CreditGrant {
193193
@visibility(Lifecycle.Create)
194194
expires_after?: Shared.ISO8601Duration;
195195

196+
/**
197+
* Idempotency key for the credit grant creation request.
198+
*
199+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
200+
* creating a duplicate grant, which makes create requests safe to retry.
201+
*/
202+
@visibility(Lifecycle.Create)
203+
key?: Shared.ExternalResourceKey;
204+
196205
/**
197206
* The timestamp when the credit grant expires.
198207
*

api/v3/api.gen.go

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

api/v3/handlers/customers/credits/convert.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ func fromAPICreateCreditGrantRequest(ns string, customerID api.ULID, body api.Cr
340340
FundingMethod: fromAPIBillingCreditFundingMethod(body.FundingMethod),
341341
Priority: body.Priority,
342342
Labels: lo.FromPtrOr(body.Labels, api.Labels{}),
343+
Key: body.Key,
343344
}
344345

345346
if body.ExpiresAfter != nil {

api/v3/openapi.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8636,6 +8636,14 @@ components:
86368636
The duration after which the credit grant expires.
86378637
86388638
Defaults to never expiring.
8639+
key:
8640+
allOf:
8641+
- $ref: '#/components/schemas/ExternalResourceKey'
8642+
description: |-
8643+
Idempotency key for the credit grant creation request.
8644+
8645+
When provided, reusing the same key returns an HTTP 409 Conflict instead of
8646+
creating a duplicate grant, which makes create requests safe to retry.
86398647
additionalProperties: false
86408648
description: CreditGrant create request.
86418649
CreateCreditGrantTaxConfig:

e2e/customer_credits_v3_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,68 @@ func TestV3CreateCreditGrantMissingTaxCode(t *testing.T) {
8484
}
8585
assert.True(t, mentionsTaxCode, "response should name the missing tax code, problem: %+v", problem)
8686
}
87+
88+
func TestV3CreateCreditGrantIdempotencyKey(t *testing.T) {
89+
c := newV3Client(t)
90+
currency := apiv3.CurrencyCode("USD")
91+
92+
createCustomer := func(prefix string) string {
93+
status, customer, problem := c.CreateCustomer(apiv3.CreateCustomerRequest{
94+
Key: uniqueKey(prefix),
95+
Name: "Credit Grant Idempotency Test Customer",
96+
Currency: &currency,
97+
})
98+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
99+
require.NotNil(t, customer)
100+
return customer.Id
101+
}
102+
103+
grant := func(key *string) apiv3.CreateCreditGrantRequest {
104+
return apiv3.CreateCreditGrantRequest{
105+
Name: "idempotency grant",
106+
Amount: apiv3.Numeric("10"),
107+
Currency: currency,
108+
FundingMethod: apiv3.BillingCreditFundingMethodNone,
109+
Key: key,
110+
}
111+
}
112+
113+
t.Run("reusing a key for the same customer returns 409", func(t *testing.T) {
114+
customerID := createCustomer("credit_grant_idem_conflict")
115+
key := ulid.Make().String()
116+
117+
status, _, problem := c.CreateCreditGrant(customerID, grant(&key))
118+
require.Equal(t, http.StatusCreated, status, "first create must succeed, problem: %+v", problem)
119+
120+
status, _, problem = c.CreateCreditGrant(customerID, grant(&key))
121+
require.Equal(t, http.StatusConflict, status, "reusing an idempotency key must be a 409, problem: %+v", problem)
122+
})
123+
124+
t.Run("omitting the key allows duplicates", func(t *testing.T) {
125+
customerID := createCustomer("credit_grant_idem_nil")
126+
127+
status, _, problem := c.CreateCreditGrant(customerID, grant(nil))
128+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
129+
130+
status, _, problem = c.CreateCreditGrant(customerID, grant(nil))
131+
require.Equal(t, http.StatusCreated, status, "grants without a key must not collide, problem: %+v", problem)
132+
})
133+
134+
t.Run("the same key conflicts across different customers in a namespace", func(t *testing.T) {
135+
key := ulid.Make().String()
136+
137+
status, _, problem := c.CreateCreditGrant(createCustomer("credit_grant_idem_cust_a"), grant(&key))
138+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
139+
140+
status, _, problem = c.CreateCreditGrant(createCustomer("credit_grant_idem_cust_b"), grant(&key))
141+
require.Equal(t, http.StatusConflict, status, "the key is unique per namespace, not per customer, problem: %+v", problem)
142+
})
143+
144+
t.Run("an over-length key is rejected with 400", func(t *testing.T) {
145+
customerID := createCustomer("credit_grant_idem_overlong")
146+
key := strings.Repeat("k", 257)
147+
148+
status, _, problem := c.CreateCreditGrant(customerID, grant(&key))
149+
require.Equal(t, http.StatusBadRequest, status, "an over-length key must be a 400, problem: %+v", problem)
150+
})
151+
}

openmeter/billing/charges/creditpurchase/adapter/charge.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func (a *adapter) CreateCharge(ctx context.Context, in creditpurchase.CreateChar
7676
SetNillablePriority(in.Intent.Priority).
7777
SetFeatureFilters(pq.StringArray(in.Intent.FeatureFilters.Normalize())).
7878
SetSettlement(in.Intent.Settlement).
79+
SetNillableKey(in.Intent.Key).
7980
SetStatusDetailed(initialStatus)
8081

8182
create, err = chargemeta.Create(create, chargemeta.CreateInput{

openmeter/billing/charges/creditpurchase/adapter/mapper.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func MapChargeBaseFromDB(dbEntity *entdb.ChargeCreditPurchase) creditpurchase.Ch
3232
FeatureFilters: creditpurchase.FeatureFilters(dbEntity.FeatureFilters).Normalize(),
3333
Settlement: dbEntity.Settlement,
3434
},
35+
Key: dbEntity.Key,
3536
},
3637
}
3738
}

openmeter/billing/charges/creditpurchase/charge.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ func (c Charge) Validate() error {
117117
type Intent struct {
118118
meta.Intent
119119
IntentMutableFields
120+
121+
// Key is the optional idempotency key: a retried create with the same key returns a conflict.
122+
Key *string `json:"key,omitempty"`
120123
}
121124

122125
type IntentMutableFields struct {

0 commit comments

Comments
 (0)