Skip to content

Commit 7a6ebdd

Browse files
committed
all: Add retry logic to CLI end_devices create
1 parent 85b089e commit 7a6ebdd

2 files changed

Lines changed: 106 additions & 26 deletions

File tree

cmd/ttn-lw-cli/commands/end_devices.go

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright © 2020 The Things Network Foundation, The Things Industries B.V.
1+
// Copyright © 2025 The Things Network Foundation, The Things Industries B.V.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -584,14 +584,18 @@ var (
584584
return err
585585
}
586586

587-
application, err := ttnpb.NewApplicationRegistryClient(is).Get(ctx, &ttnpb.GetApplicationRequest{
588-
ApplicationIds: devID.ApplicationIds,
589-
FieldMask: ttnpb.FieldMask(
590-
"network_server_address",
591-
"application_server_address",
592-
"join_server_address",
593-
),
594-
})
587+
application, err := api.CallWithBackoff(
588+
func() (*ttnpb.Application, error) {
589+
return ttnpb.NewApplicationRegistryClient(is).Get(ctx, &ttnpb.GetApplicationRequest{
590+
ApplicationIds: devID.ApplicationIds,
591+
FieldMask: ttnpb.FieldMask(
592+
"network_server_address",
593+
"application_server_address",
594+
"join_server_address",
595+
),
596+
})
597+
},
598+
)
595599
if err != nil {
596600
return err
597601
}
@@ -604,7 +608,11 @@ var (
604608
return errEndDeviceClaimGeneratedEUI.New()
605609
}
606610
logger.Debug("request-dev-eui flag set, requesting a DevEUI")
607-
devEUIResponse, err := ttnpb.NewApplicationRegistryClient(is).IssueDevEUI(ctx, devID.ApplicationIds)
611+
devEUIResponse, err := api.CallWithBackoff(
612+
func() (*ttnpb.IssueDevEUIResponse, error) {
613+
return ttnpb.NewApplicationRegistryClient(is).IssueDevEUI(ctx, devID.ApplicationIds)
614+
},
615+
)
608616
if err != nil {
609617
return err
610618
}
@@ -634,26 +642,34 @@ var (
634642
if err != nil {
635643
return err
636644
}
637-
claimInfoResp, err := ttnpb.NewEndDeviceClaimingServerClient(dcs).GetInfoByJoinEUI(ctx, &ttnpb.GetInfoByJoinEUIRequest{
638-
JoinEui: device.Ids.JoinEui,
639-
})
645+
claimInfoResp, err := api.CallWithBackoff(
646+
func() (*ttnpb.GetInfoByJoinEUIResponse, error) {
647+
return ttnpb.NewEndDeviceClaimingServerClient(dcs).GetInfoByJoinEUI(ctx, &ttnpb.GetInfoByJoinEUIRequest{
648+
JoinEui: device.Ids.JoinEui,
649+
})
650+
},
651+
)
640652
if err != nil {
641653
return errEndDeviceClaimInfo.WithCause(err)
642654
}
643655
if !claimInfoResp.SupportsClaiming {
644656
return errClaimingNotSupported.WithAttributes("join_eui", types.MustEUI64(device.Ids.JoinEui).String())
645657
}
646-
_, err = ttnpb.NewEndDeviceClaimingServerClient(dcs).Claim(ctx, &ttnpb.ClaimEndDeviceRequest{
647-
TargetApplicationIds: device.Ids.ApplicationIds,
648-
TargetDeviceId: device.Ids.DeviceId,
649-
SourceDevice: &ttnpb.ClaimEndDeviceRequest_AuthenticatedIdentifiers_{
650-
AuthenticatedIdentifiers: &ttnpb.ClaimEndDeviceRequest_AuthenticatedIdentifiers{
651-
JoinEui: device.Ids.JoinEui,
652-
DevEui: device.Ids.DevEui,
653-
AuthenticationCode: device.ClaimAuthenticationCode.Value,
654-
},
658+
_, err = api.CallWithBackoff(
659+
func() (*ttnpb.EndDeviceIdentifiers, error) {
660+
return ttnpb.NewEndDeviceClaimingServerClient(dcs).Claim(ctx, &ttnpb.ClaimEndDeviceRequest{
661+
TargetApplicationIds: device.Ids.ApplicationIds,
662+
TargetDeviceId: device.Ids.DeviceId,
663+
SourceDevice: &ttnpb.ClaimEndDeviceRequest_AuthenticatedIdentifiers_{
664+
AuthenticatedIdentifiers: &ttnpb.ClaimEndDeviceRequest_AuthenticatedIdentifiers{
665+
JoinEui: device.Ids.JoinEui,
666+
DevEui: device.Ids.DevEui,
667+
AuthenticationCode: device.ClaimAuthenticationCode.Value,
668+
},
669+
},
670+
})
655671
},
656-
})
672+
)
657673
if err != nil {
658674
return errEndDeviceClaim.WithCause(err)
659675
}
@@ -672,9 +688,13 @@ var (
672688
if err := isDevice.SetFields(device, append(isPaths, "ids")...); err != nil {
673689
return err
674690
}
675-
isRes, err := ttnpb.NewEndDeviceRegistryClient(is).Create(ctx, &ttnpb.CreateEndDeviceRequest{
676-
EndDevice: isDevice,
677-
})
691+
isRes, err := api.CallWithBackoff(
692+
func() (*ttnpb.EndDevice, error) {
693+
return ttnpb.NewEndDeviceRegistryClient(is).Create(ctx, &ttnpb.CreateEndDeviceRequest{
694+
EndDevice: isDevice,
695+
})
696+
},
697+
)
678698
if err != nil {
679699
return err
680700
}

cmd/ttn-lw-cli/internal/api/grpc.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ import (
2828
"go.thethings.network/lorawan-stack/v3/pkg/rpcmiddleware/rpclog"
2929
"go.thethings.network/lorawan-stack/v3/pkg/rpcmiddleware/rpcretry"
3030
"google.golang.org/grpc"
31+
"google.golang.org/grpc/codes"
3132
"google.golang.org/grpc/credentials"
3233
"google.golang.org/grpc/credentials/insecure"
34+
"google.golang.org/grpc/status"
35+
"google.golang.org/protobuf/proto"
3336
)
3437

3538
var (
@@ -188,3 +191,60 @@ func CloseAll() {
188191
conn.Close()
189192
}
190193
}
194+
195+
type retry[T proto.Message] struct {
196+
backoff time.Duration
197+
maxRetries int
198+
}
199+
200+
type retryOption[T proto.Message] func(*retry[T])
201+
202+
// WithBackoff sets the initial backoff duration between retries.
203+
func WithBackoff[T proto.Message](d time.Duration) retryOption[T] {
204+
return func(r *retry[T]) {
205+
r.backoff = d
206+
}
207+
}
208+
209+
// WithMaxRetries sets the maximum number of retry attempts.
210+
func WithMaxRetries[T proto.Message](n int) retryOption[T] {
211+
return func(r *retry[T]) {
212+
r.maxRetries = n
213+
}
214+
}
215+
216+
// CallWithBackoff executes a gRPC call with retry logic and exponential backoff.
217+
//
218+
// It takes a function `call` that performs the actual gRPC request and retries it
219+
// if the error returned is a gRPC `codes.ResourceExhausted` error. The retry behavior
220+
// (initial backoff duration and maximum number of retries) can be customized by
221+
// providing functional options (e.g., WithBackoff, WithMaxRetries). If no options are
222+
// provided, it defaults to a 1-second backoff and 5 maximum retries.
223+
func CallWithBackoff[T proto.Message](call func() (T, error), opts ...retryOption[T]) (T, error) {
224+
r := retry[T]{
225+
backoff: time.Second,
226+
maxRetries: 5,
227+
}
228+
for _, opt := range opts {
229+
opt(&r)
230+
}
231+
return r.callWithBackoff(call)
232+
}
233+
234+
func (r retry[T]) callWithBackoff(call func() (T, error)) (T, error) {
235+
var zero T
236+
backoff := r.backoff
237+
for attempt := 0; attempt < r.maxRetries; attempt++ {
238+
v, err := call()
239+
if err == nil {
240+
return v, nil
241+
}
242+
st, ok := status.FromError(err)
243+
if !ok || st.Code() != codes.ResourceExhausted {
244+
return zero, err
245+
}
246+
time.Sleep(backoff)
247+
backoff *= 2
248+
}
249+
return call()
250+
}

0 commit comments

Comments
 (0)