Skip to content

Commit 06c2538

Browse files
Default to adding idempotecy-key to audit-logs/events if not present, added in retryability logic to endpoints#492 (#475)
1 parent 33538f0 commit 06c2538

File tree

6 files changed

+156
-12
lines changed

6 files changed

+156
-12
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ go 1.13
44

55
require (
66
github.com/google/go-querystring v1.0.0
7+
github.com/google/uuid v1.6.0
78
github.com/stretchr/testify v1.10.0
89
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
33
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
44
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
55
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
6+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
68
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
79
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
810
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

pkg/auditlogs/auditlogs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
// Target{ID: "team_123", Type: "team"},
2626
// },
2727
// },
28-
// IdempotencyKey: uuid.New().String(),
28+
// IdempotencyKey: uuid.New().String(), - auto-generated if not provided
2929
// })
3030
// if err != nil {
3131
// // Handle error.

pkg/auditlogs/client.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import (
88
"sync"
99
"time"
1010

11-
"github.com/workos/workos-go/v6/pkg/workos_errors"
12-
11+
"github.com/google/uuid"
1312
"github.com/workos/workos-go/v6/internal/workos"
13+
"github.com/workos/workos-go/v6/pkg/retryablehttp"
14+
"github.com/workos/workos-go/v6/pkg/workos_errors"
1415
)
1516

1617
// ResponseLimit is the default number of records to limit a response to.
@@ -31,9 +32,11 @@ type Client struct {
3132
// https://dashboard.workos.com/api-keys.
3233
APIKey string
3334

34-
// The http.Client that is used to post Audit Log events to WorkOS. Defaults
35-
// to http.Client.
36-
HTTPClient *http.Client
35+
// The http.Client that is used to post Audit Log events to WorkOS.
36+
// Defaults to retryablehttp.HttpClient with automatic retry logic.
37+
HTTPClient interface {
38+
Do(req *http.Request) (*http.Response, error)
39+
}
3740

3841
// The endpoint used to request WorkOS AuditLog events creation endpoint.
3942
// Defaults to https://api.workos.com/audit_logs/events.
@@ -57,8 +60,8 @@ type CreateEventOpts struct {
5760
// Event payload
5861
Event Event `json:"event" binding:"required"`
5962

60-
// If no key is provided or the key is empty, the key will not be attached
61-
// to the request.
63+
// Optional idempotency key for deduplication. If not provided or empty,
64+
// the SDK will automatically generate a UUID v4.
6265
IdempotencyKey string `json:"-"`
6366
}
6467

@@ -183,7 +186,10 @@ type GetExportOpts struct {
183186

184187
func (c *Client) init() {
185188
if c.HTTPClient == nil {
186-
c.HTTPClient = &http.Client{Timeout: 10 * time.Second}
189+
// Use retryable HTTP client by default for better reliability
190+
c.HTTPClient = &retryablehttp.HttpClient{
191+
Client: http.Client{Timeout: 10 * time.Second},
192+
}
187193
}
188194

189195
if c.EventsEndpoint == "" {
@@ -219,9 +225,12 @@ func (c *Client) CreateEvent(ctx context.Context, e CreateEventOpts) error {
219225
req.Header.Set("Authorization", "Bearer "+c.APIKey)
220226
req.Header.Set("User-Agent", "workos-go/"+workos.Version)
221227

222-
if e.IdempotencyKey != "" {
223-
req.Header.Set("Idempotency-Key", e.IdempotencyKey)
228+
// Auto-generate idempotency key if not provided
229+
idempotencyKey := e.IdempotencyKey
230+
if idempotencyKey == "" {
231+
idempotencyKey = "workos-go-" + uuid.New().String()
224232
}
233+
req.Header.Set("Idempotency-Key", idempotencyKey)
225234

226235
res, err := c.HTTPClient.Do(req)
227236
if err != nil {

pkg/auditlogs/client_test.go

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ package auditlogs
33
import (
44
"context"
55
"encoding/json"
6-
"github.com/workos/workos-go/v6/pkg/workos_errors"
76
"net/http"
87
"net/http/httptest"
98
"testing"
109
"time"
1110

1211
"github.com/stretchr/testify/require"
12+
"github.com/workos/workos-go/v6/pkg/retryablehttp"
13+
"github.com/workos/workos-go/v6/pkg/workos_errors"
1314
)
1415

1516
var event = CreateEventOpts{
@@ -283,3 +284,129 @@ func TestGetExports(t *testing.T) {
283284
type defaultTestHandler struct {
284285
header *http.Header
285286
}
287+
288+
func TestCreateEvent_AutoGeneratesIdempotencyKey(t *testing.T) {
289+
// Test that when IdempotencyKey is empty, SDK auto-generates one
290+
291+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
292+
idempotencyKey := r.Header.Get("Idempotency-Key")
293+
294+
// Assert idempotency key was sent
295+
require.NotEmpty(t, idempotencyKey, "Expected Idempotency-Key header to be present")
296+
297+
// Assert it has the workos-go prefix and UUID format (10 chars for "workos-go-" + 36 for UUID)
298+
require.Equal(t, 46, len(idempotencyKey), "Expected 'workos-go-' prefix + UUID format (46 characters total)")
299+
require.Contains(t, idempotencyKey, "workos-go-", "Expected idempotency key to start with 'workos-go-'")
300+
301+
w.WriteHeader(http.StatusOK)
302+
}))
303+
defer server.Close()
304+
305+
client := &Client{
306+
APIKey: "test_key",
307+
EventsEndpoint: server.URL,
308+
HTTPClient: server.Client(),
309+
}
310+
311+
err := client.CreateEvent(context.Background(), CreateEventOpts{
312+
OrganizationID: "org_123",
313+
Event: Event{
314+
Action: "test.action",
315+
Actor: Actor{ID: "user_123", Type: "user", Name: "Test User"},
316+
Targets: []Target{
317+
{ID: "target_123", Type: "test", Name: "Test Target"},
318+
},
319+
Context: Context{
320+
Location: "127.0.0.1",
321+
UserAgent: "test",
322+
},
323+
},
324+
// Note: NOT providing IdempotencyKey
325+
})
326+
327+
require.NoError(t, err)
328+
}
329+
330+
func TestCreateEvent_UsesProvidedIdempotencyKey(t *testing.T) {
331+
// Test that when user provides IdempotencyKey, SDK uses it
332+
333+
expectedKey := "user-provided-key-12345"
334+
335+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
336+
idempotencyKey := r.Header.Get("Idempotency-Key")
337+
338+
require.Equal(t, expectedKey, idempotencyKey, "Expected provided idempotency key to be used")
339+
340+
w.WriteHeader(http.StatusOK)
341+
}))
342+
defer server.Close()
343+
344+
client := &Client{
345+
APIKey: "test_key",
346+
EventsEndpoint: server.URL,
347+
HTTPClient: server.Client(),
348+
}
349+
350+
err := client.CreateEvent(context.Background(), CreateEventOpts{
351+
OrganizationID: "org_123",
352+
Event: Event{
353+
Action: "test.action",
354+
Actor: Actor{ID: "user_123", Type: "user", Name: "Test User"},
355+
Targets: []Target{
356+
{ID: "target_123", Type: "test", Name: "Test Target"},
357+
},
358+
Context: Context{
359+
Location: "127.0.0.1",
360+
UserAgent: "test",
361+
},
362+
},
363+
IdempotencyKey: expectedKey, // User provides their own key
364+
})
365+
366+
require.NoError(t, err)
367+
}
368+
369+
func TestCreateEvent_RetriesOn5xxErrors(t *testing.T) {
370+
// Test that SDK retries on 5xx errors
371+
372+
attempts := 0
373+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
374+
attempts++
375+
if attempts < 3 {
376+
// First 2 attempts fail with 500
377+
w.Header().Set("Content-Type", "application/json")
378+
w.WriteHeader(http.StatusInternalServerError)
379+
w.Write([]byte(`{"message": "Internal server error"}`))
380+
} else {
381+
// Third attempt succeeds
382+
w.WriteHeader(http.StatusOK)
383+
}
384+
}))
385+
defer server.Close()
386+
387+
client := &Client{
388+
APIKey: "test_key",
389+
EventsEndpoint: server.URL,
390+
HTTPClient: &retryablehttp.HttpClient{
391+
Client: http.Client{Timeout: 10 * time.Second},
392+
},
393+
}
394+
395+
err := client.CreateEvent(context.Background(), CreateEventOpts{
396+
OrganizationID: "org_123",
397+
Event: Event{
398+
Action: "test.action",
399+
Actor: Actor{ID: "user_123", Type: "user", Name: "Test User"},
400+
Targets: []Target{
401+
{ID: "target_123", Type: "test", Name: "Test Target"},
402+
},
403+
Context: Context{
404+
Location: "127.0.0.1",
405+
UserAgent: "test",
406+
},
407+
},
408+
})
409+
410+
require.NoError(t, err, "CreateEvent should succeed after retries")
411+
require.Equal(t, 3, attempts, "Expected 3 attempts")
412+
}

pkg/retryablehttp/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ func (client *HttpClient) Do(req *http.Request) (*http.Response, error) {
4848
break
4949
}
5050

51+
// Close the response body before retrying to prevent resource leak
52+
if res.Body != nil {
53+
res.Body.Close()
54+
}
55+
5156
sleepTime := client.sleepTime(retry)
5257
retry++
5358

0 commit comments

Comments
 (0)