Skip to content

Commit d68eff6

Browse files
committed
fix(api): add idempotency key to create credit grant
1 parent 5ecf671 commit d68eff6

24 files changed

Lines changed: 705 additions & 364 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
@@ -3976,6 +3976,7 @@ export const createCreditGrantRequest = z
39763976
'Draw-down priority of the grant. Lower values have higher priority.',
39773977
),
39783978
expires_after: iso8601Duration.optional(),
3979+
key: externalResourceKey.optional(),
39793980
})
39803981
.describe('CreditGrant create request.')
39813982

@@ -4018,6 +4019,7 @@ export const creditGrant = z
40184019
'Draw-down priority of the grant. Lower values have higher priority.',
40194020
),
40204021
expires_after: iso8601Duration.optional(),
4022+
key: externalResourceKey.optional(),
40214023
expires_at: dateTime.optional(),
40224024
voided_at: dateTime.optional(),
40234025
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
@@ -3582,6 +3582,13 @@ export interface CreateCreditGrantRequest {
35823582
* Defaults to never expiring.
35833583
*/
35843584
expires_after?: string
3585+
/**
3586+
* Idempotency key for the credit grant creation request.
3587+
*
3588+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
3589+
* creating a duplicate grant, which makes create requests safe to retry.
3590+
*/
3591+
key?: string
35853592
}
35863593

35873594
/**
@@ -3639,6 +3646,13 @@ export interface CreditGrant {
36393646
* Defaults to never expiring.
36403647
*/
36413648
expires_after?: string
3649+
/**
3650+
* Idempotency key for the credit grant creation request.
3651+
*
3652+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
3653+
* creating a duplicate grant, which makes create requests safe to retry.
3654+
*/
3655+
key?: string
36423656
/**
36433657
* The timestamp when the credit grant expires.
36443658
*
@@ -5391,6 +5405,13 @@ export interface CreateCreditGrantRequestInput {
53915405
* Defaults to never expiring.
53925406
*/
53935407
expires_after?: string
5408+
/**
5409+
* Idempotency key for the credit grant creation request.
5410+
*
5411+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
5412+
* creating a duplicate grant, which makes create requests safe to retry.
5413+
*/
5414+
key?: string
53945415
}
53955416

53965417
export interface CreditGrantInput {
@@ -5442,6 +5463,13 @@ export interface CreditGrantInput {
54425463
* Defaults to never expiring.
54435464
*/
54445465
expires_after?: string
5466+
/**
5467+
* Idempotency key for the credit grant creation request.
5468+
*
5469+
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
5470+
* creating a duplicate grant, which makes create requests safe to retry.
5471+
*/
5472+
key?: string
54455473
/**
54465474
* The timestamp when the credit grant expires.
54475475
*

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: 337 additions & 329 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
@@ -8599,6 +8599,14 @@ components:
85998599
The duration after which the credit grant expires.
86008600
86018601
Defaults to never expiring.
8602+
key:
8603+
allOf:
8604+
- $ref: '#/components/schemas/ExternalResourceKey'
8605+
description: |-
8606+
Idempotency key for the credit grant creation request.
8607+
8608+
When provided, reusing the same key returns an HTTP 409 Conflict instead of
8609+
creating a duplicate grant, which makes create requests safe to retry.
86028610
additionalProperties: false
86038611
description: CreditGrant create request.
86048612
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)