diff --git a/.gitignore b/.gitignore index 2253c3a..022b561 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ # Dependency directories (remove the comment below to include it) # vendor/ +*.DS_store examples/.DS_Store diff --git a/batchofferrequests.go b/batchofferrequests.go new file mode 100644 index 0000000..2f47f11 --- /dev/null +++ b/batchofferrequests.go @@ -0,0 +1,45 @@ +package duffel + +import ( + "context" +) + +type ( + BatchOfferRequestClient interface { + CreateBatchOfferRequest(ctx context.Context, requestInput CreateBatchOfferRequestInput) (*BatchOfferRequest, error) + GetBatchOfferRequest(ctx context.Context, id string) (*BatchOfferRequest, error) + } + + CreateBatchOfferRequestInput struct { + // The passengers who want to travel. If you specify an age for a passenger, the type may differ for the same passenger in different offers due to airline's different rules. e.g. one airline may treat a 14 year old as an adult, and another as a young adult. You may only specify an age or a type – not both. + Passengers []OfferRequestPassenger `json:"passengers" url:"-"` + // The slices that make up this offer request. One-way journeys can be expressed using one slice, whereas return trips will need two. + Slices []OfferRequestSlice `json:"slices" url:"-"` + // The cabin that the passengers want to travel in + CabinClass CabinClass `json:"cabin_class" url:"-"` + // The maximum number of connections within any slice of the offer. For example 0 means a direct flight which will have a single segment within each slice and 1 means a maximum of two segments within each slice of the offer. + MaxConnections *int `json:"max_connections,omitempty" url:"-"` + // The maximum amount of time in milliseconds to wait for each airline to respond + SupplierTimeout int `json:"-" url:"supplier_timeout,omitempty"` + } + + BatchOfferRequest struct { + TotalBatches int `json:"total_batches"` + RemainingBatches int `json:"remaining_batches"` + ID string `json:"id"` + Offers []Offer `json:"offers,omitempty"` + CreatedAt DateTime `json:"created_at"` + } +) + +func (a *API) CreateBatchOfferRequest(ctx context.Context, requestInput CreateBatchOfferRequestInput) (*BatchOfferRequest, error) { + return newRequestWithAPI[CreateBatchOfferRequestInput, BatchOfferRequest](a). + Post("/air/batch_offer_requests", &requestInput). + Single(ctx) +} + +func (a *API) GetBatchOfferRequest(ctx context.Context, id string) (*BatchOfferRequest, error) { + return newRequestWithAPI[EmptyPayload, BatchOfferRequest](a). + Getf("/air/batch_offer_requests/%s", id). + Single(ctx) +} diff --git a/batchofferrequests_test.go b/batchofferrequests_test.go new file mode 100644 index 0000000..fdd892e --- /dev/null +++ b/batchofferrequests_test.go @@ -0,0 +1,76 @@ +package duffel + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestCreateBatchOfferRequest(t *testing.T) { + defer gock.Off() + a := assert.New(t) + gock.New("https://api.duffel.com"). + Post("/air/batch_offer_requests"). + Reply(200). + SetHeader("Ratelimit-Limit", "5"). + SetHeader("Ratelimit-Remaining", "5"). + SetHeader("Ratelimit-Reset", time.Now().Format(time.RFC1123)). + SetHeader("Date", time.Now().Format(time.RFC1123)). + File("fixtures/200-create-batch-offer-request.json") + + ctx := context.TODO() + + client := New("duffel_test_123") + data, err := client.CreateBatchOfferRequest(ctx, CreateBatchOfferRequestInput{ + Passengers: []OfferRequestPassenger{ + { + FamilyName: "Earhardt", + GivenName: "Amelia", + Type: PassengerTypeAdult, + }, + { + Age: 14, + }, + }, + CabinClass: CabinClassEconomy, + Slices: []OfferRequestSlice{ + { + DepartureDate: Date(time.Now().AddDate(0, 0, 7)), + Origin: "JFK", + Destination: "AUS", + }, + }, + }) + a.NoError(err) + a.NotNil(data) + + a.Equal(7, data.RemainingBatches) + a.Equal(7, data.TotalBatches) +} + +func TestGetBatchOfferRequest(t *testing.T) { + defer gock.Off() + a := assert.New(t) + gock.New("https://api.duffel.com"). + Get("/air/batch_offer_requests/orq_0000AhTmH2Thpl6RrM97qK"). + Reply(200). + SetHeader("Ratelimit-Limit", "5"). + SetHeader("Ratelimit-Remaining", "5"). + SetHeader("Ratelimit-Reset", time.Now().Format(time.RFC1123)). + SetHeader("Date", time.Now().Format(time.RFC1123)). + File("fixtures/200-get-batch-offer-request.json") + + ctx := context.TODO() + + client := New("duffel_test_123") + + data, err := client.GetBatchOfferRequest(ctx, "orq_0000AhTmH2Thpl6RrM97qK") + a.NoError(err) + a.NotNil(data) + + a.Equal(2, data.TotalBatches) + a.Equal(2, data.RemainingBatches) +} diff --git a/client.go b/client.go index 41957d4..76eda9b 100644 --- a/client.go +++ b/client.go @@ -10,10 +10,12 @@ import ( "context" "fmt" "io" + "io/ioutil" "net/http" "net/http/httputil" "net/url" "strings" + "time" "github.com/segmentio/encoding/json" ) @@ -95,25 +97,72 @@ func (c *client[R, T]) makeRequest(ctx context.Context, resourceName string, met fmt.Printf("REQUEST:\n%s\n", string(b)) } - resp, err := c.httpDoer.Do(req) - if err != nil { - return nil, err - } + do := func(c *client[R, T], req *http.Request, reuse bool) (*http.Response, error) { + if reuse && req.Body != nil { + // In a way when we use retry functionality we have to copy + // request and pass it to c.httpDoer.Do, but req.Clone() doesn't really deep clone Body + // and we have to clone body manually as in httputil.DumpRequestOut + // + // Issue https://github.com/golang/go/issues/36095 + var b bytes.Buffer + b.ReadFrom(req.Body) + req.Body = ioutil.NopCloser(&b) + + cloneReq := req.Clone(ctx) + cloneReq.Body = ioutil.NopCloser(bytes.NewReader(b.Bytes())) + req = cloneReq + } - if c.options.Debug { - b, err := httputil.DumpResponse(resp, true) + resp, err := c.httpDoer.Do(req) if err != nil { return nil, err } - fmt.Printf("RESPONSE:\n%s\n", string(b)) + + if c.options.Debug { + b, err := httputil.DumpResponse(resp, true) + if err != nil { + return nil, err + } + fmt.Printf("RESPONSE:\n%s\n", string(b)) + } + + if resp.StatusCode >= 400 { + err = decodeError(resp) + return nil, err + } + return resp, nil } - if resp.StatusCode > 399 { - err = decodeError(resp) - return nil, err + if c.retry == nil { + // Do single request without using backoff retry mechanism + return do(c, req, false) } - return resp, nil + for { + resp, err := do(c, req, true) + + var isMatchedCond bool + for _, cond := range c.options.Retry.Conditions { + if ok := cond(resp, err); ok { + isMatchedCond = true + break + } + } + if isMatchedCond { + // Get next duration internval, sleep and make another request + // till nextDuration != stopBackoff + nextDuration := c.retry.next() + if nextDuration == stopBackoff { + c.retry.reset() + return resp, err + } + time.Sleep(nextDuration) + continue + } + + // Break retries mechanism if conditions weren't matched + return resp, err + } } func (c *client[R, T]) buildRequestURL(resourceName string) (*url.URL, error) { diff --git a/client_test.go b/client_test.go index 319d8ce..e2b7ac8 100644 --- a/client_test.go +++ b/client_test.go @@ -6,7 +6,9 @@ package duffel import ( "context" + "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" @@ -59,3 +61,27 @@ func TestClientErrorBadGateway(t *testing.T) { a.Nil(data) a.Equal("duffel: An internal server error occurred. Please try again later.", err.Error()) } + +func TestClientRetry(t *testing.T) { + ctx := context.TODO() + a := assert.New(t) + gock.New("https://api.duffel.com/air/offer_requests"). + Persist(). + Reply(502). + AddHeader("Content-Type", "text/html"). + File("fixtures/502-bad-gateway.html") + defer gock.Off() + + client := New("duffel_test_123", + WithRetry(3, time.Second, time.Second*5, ExponentalBackoff), + WithRetryCondition(func(resp *http.Response, err error) bool { + return err != nil + }), + ) + data, err := client.CreateOfferRequest(ctx, OfferRequestInput{ + ReturnOffers: true, + }) + a.Error(err) + a.Nil(data) + a.Equal("duffel: An internal server error occurred. Please try again later.", err.Error()) +} diff --git a/duffel.go b/duffel.go index f35bf49..bd4dbea 100644 --- a/duffel.go +++ b/duffel.go @@ -17,6 +17,7 @@ const defaultHost = "https://api.duffel.com/" type ( Duffel interface { OfferRequestClient + BatchOfferRequestClient OfferClient OrderClient OrderChangeClient @@ -66,6 +67,7 @@ type ( OperatingCarrier Airline `json:"operating_carrier"` MarketingCarrierFlightNumber string `json:"marketing_carrier_flight_number"` MarketingCarrier Airline `json:"marketing_carrier"` + Stops []FlightStop `json:"stops,omitempty"` Duration Duration `json:"duration"` Distance Distance `json:"distance,omitempty"` DestinationTerminal string `json:"destination_terminal"` @@ -75,6 +77,13 @@ type ( Aircraft Aircraft `json:"aircraft"` } + FlightStop struct { + ID string `json:"id"` + Duration Duration `json:"duration"` + RawDepartingAt string `json:"departing_at"` + Airport Airport `json:"airport"` + } + SegmentPassenger struct { ID string `json:"passenger_id"` FareBasisCode string `json:"fare_basis_code"` @@ -237,7 +246,16 @@ type ( Host string UserAgent string HttpDoer *http.Client - Debug bool + Retry struct { + MaxAttempts int + MinWaitTime time.Duration + MaxWaitTime time.Duration + // Conditions that will be applied on retry mechanism. + Conditions []RetryCond + // Retry function which describes backoff algorithm. + Fn RetryFunc + } + Debug bool } client[Req any, Resp any] struct { @@ -246,6 +264,7 @@ type ( options *Options limiter *rate.Limiter rateLimit *RateLimit + retry *backoff afterResponse []func(resp *http.Response) } diff --git a/fixtures/200-create-batch-offer-request.json b/fixtures/200-create-batch-offer-request.json new file mode 100644 index 0000000..e9400a0 --- /dev/null +++ b/fixtures/200-create-batch-offer-request.json @@ -0,0 +1,10 @@ +{ + "data": { + "remaining_batches": 7, + "total_batches": 7, + "client_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTQ3MzU4NDAsImxpdmVfbW9kZSI6ZmFsc2UsIm9yZ2FuaXNhdGlvbl9pZCI6Im9yZ18wMDAwQU4xdXZobHNaQlF6RDhPSTFZIn0.1dOy3eTaUskIgfphTgAlW0aStrwklPModabSf-Znam0", + "created_at": "2024-05-02T11:30:40.589820Z", + "live_mode": false, + "id": "orq_0000AhTmH2Thpl6RrM97qK" + } +} \ No newline at end of file diff --git a/fixtures/200-get-batch-offer-request.json b/fixtures/200-get-batch-offer-request.json new file mode 100644 index 0000000..963ecab --- /dev/null +++ b/fixtures/200-get-batch-offer-request.json @@ -0,0 +1,322 @@ +{ + "data": { + "total_batches": 2, + "remaining_batches": 2, + "offers": [ + { + "total_emissions_kg": "460", + "total_currency": "GBP", + "total_amount": "45.00", + "tax_currency": "GBP", + "tax_amount": "40.80", + "supported_passenger_identity_document_types": [ + "passport" + ], + "slices": [ + { + "segments": [ + { + "stops": [ + { + "id": "sto_00009htYpSCXrwaB9Dn456", + "duration": "PT02H26M", + "departing_at": "2020-06-13T16:38:02", + "arriving_at": "2020-06-13T16:38:02", + "airport": { + "type": "airport", + "time_zone": "Europe/London", + "name": "Heathrow", + "longitude": -141.951519, + "latitude": 64.068865, + "id": "arp_lhr_gb", + "icao_code": "EGLL", + "iata_country_code": "GB", + "iata_code": "LHR", + "iata_city_code": "LON", + "city_name": "London", + "city": { + "name": "London", + "id": "cit_lon_gb", + "iata_country_code": "GB", + "iata_code": "LON" + }, + "airports": [ + { + "time_zone": "Europe/London", + "name": "Heathrow", + "longitude": -141.951519, + "latitude": 64.068865, + "id": "arp_lhr_gb", + "icao_code": "EGLL", + "iata_country_code": "GB", + "iata_code": "LHR", + "iata_city_code": "LON", + "city_name": "London", + "city": { + "name": "London", + "id": "cit_lon_gb", + "iata_country_code": "GB", + "iata_code": "LON" + } + } + ] + } + } + ], + "passengers": [ + { + "passenger_id": "passenger_0", + "fare_basis_code": "OXZ0RO", + "cabin_class_marketing_name": "Economy Basic", + "cabin_class": "economy", + "cabin": { + "name": "economy", + "marketing_name": "Economy Basic", + "amenities": { + "wifi": { + "cost": "free", + "available": "true" + }, + "seat": { + "pitch": "32", + "legroom": "standard" + }, + "power": { + "available": "true" + } + } + }, + "baggages": [ + { + "type": "checked", + "quantity": 1 + } + ] + } + ], + "origin_terminal": "B", + "origin": { + "time_zone": "Europe/London", + "name": "Heathrow", + "longitude": -141.951519, + "latitude": 64.068865, + "id": "arp_lhr_gb", + "icao_code": "EGLL", + "iata_country_code": "GB", + "iata_code": "LHR", + "iata_city_code": "LON", + "city_name": "London", + "city": { + "name": "London", + "id": "cit_lon_gb", + "iata_country_code": "GB", + "iata_code": "LON" + } + }, + "operating_carrier_flight_number": "4321", + "operating_carrier": { + "name": "British Airways", + "logo_symbol_url": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/BA.svg", + "logo_lockup_url": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/BA.svg", + "id": "arl_00001876aqC8c5umZmrRds", + "iata_code": "BA", + "conditions_of_carriage_url": "https://www.britishairways.com/en-gb/information/legal/british-airways/general-conditions-of-carriage" + }, + "marketing_carrier_flight_number": "1234", + "marketing_carrier": { + "name": "British Airways", + "logo_symbol_url": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/BA.svg", + "logo_lockup_url": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/BA.svg", + "id": "arl_00001876aqC8c5umZmrRds", + "iata_code": "BA", + "conditions_of_carriage_url": "https://www.britishairways.com/en-gb/information/legal/british-airways/general-conditions-of-carriage" + }, + "id": "seg_00009htYpSCXrwaB9Dn456", + "duration": "PT02H26M", + "distance": "424.2", + "destination_terminal": "5", + "destination": { + "time_zone": "America/New_York", + "name": "John F. Kennedy International Airport", + "longitude": -73.778519, + "latitude": 40.640556, + "id": "arp_jfk_us", + "icao_code": "KJFK", + "iata_country_code": "US", + "iata_code": "JFK", + "iata_city_code": "NYC", + "city_name": "New York", + "city": { + "name": "New York", + "id": "cit_nyc_us", + "iata_country_code": "US", + "iata_code": "NYC" + } + }, + "departing_at": "2020-06-13T16:38:02", + "arriving_at": "2020-06-13T16:38:02", + "aircraft": { + "name": "Airbus Industries A380", + "id": "arc_00009UhD4ongolulWd91Ky", + "iata_code": "380" + } + } + ], + "origin_type": "airport", + "origin": { + "type": "airport", + "time_zone": "Europe/London", + "name": "Heathrow", + "longitude": -141.951519, + "latitude": 64.068865, + "id": "arp_lhr_gb", + "icao_code": "EGLL", + "iata_country_code": "GB", + "iata_code": "LHR", + "iata_city_code": "LON", + "city_name": "London", + "city": { + "name": "London", + "id": "cit_lon_gb", + "iata_country_code": "GB", + "iata_code": "LON" + }, + "airports": [ + { + "time_zone": "Europe/London", + "name": "Heathrow", + "longitude": -141.951519, + "latitude": 64.068865, + "id": "arp_lhr_gb", + "icao_code": "EGLL", + "iata_country_code": "GB", + "iata_code": "LHR", + "iata_city_code": "LON", + "city_name": "London", + "city": { + "name": "London", + "id": "cit_lon_gb", + "iata_country_code": "GB", + "iata_code": "LON" + } + } + ] + }, + "id": "sli_00009htYpSCXrwaB9Dn123", + "fare_brand_name": "Basic", + "duration": "PT02H26M", + "destination_type": "airport", + "destination": { + "type": "airport", + "time_zone": "Europe/London", + "name": "Heathrow", + "longitude": -141.951519, + "latitude": 64.068865, + "id": "arp_lhr_gb", + "icao_code": "EGLL", + "iata_country_code": "GB", + "iata_code": "LHR", + "iata_city_code": "LON", + "city_name": "London", + "city": { + "name": "London", + "id": "cit_lon_gb", + "iata_country_code": "GB", + "iata_code": "LON" + }, + "airports": [ + { + "time_zone": "Europe/London", + "name": "Heathrow", + "longitude": -141.951519, + "latitude": 64.068865, + "id": "arp_lhr_gb", + "icao_code": "EGLL", + "iata_country_code": "GB", + "iata_code": "LHR", + "iata_city_code": "LON", + "city_name": "London", + "city": { + "name": "London", + "id": "cit_lon_gb", + "iata_country_code": "GB", + "iata_code": "LON" + } + } + ] + }, + "conditions": { + "change_before_departure": { + "penalty_currency": "GBP", + "penalty_amount": "100.00", + "allowed": true + } + }, + "comparison_key": "BmlZDw==" + } + ], + "private_fares": [ + { + "type": "corporate", + "tracking_reference": "ABN:2345678", + "tour_code": "578DFL", + "corporate_code": "FLX53" + } + ], + "payment_requirements": { + "requires_instant_payment": false, + "price_guarantee_expires_at": "2020-01-17T10:42:14Z", + "payment_required_by": "2020-01-17T10:42:14Z" + }, + "passengers": [ + { + "type": "adult", + "loyalty_programme_accounts": [ + { + "airline_iata_code": "BA", + "account_number": "12901014" + } + ], + "id": "pas_00009hj8USM7Ncg31cBCL", + "given_name": "Amelia", + "fare_type": "contract_bulk", + "family_name": "Earhart", + "age": 14 + } + ], + "passenger_identity_documents_required": false, + "partial": true, + "owner": { + "name": "British Airways", + "logo_symbol_url": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/BA.svg", + "logo_lockup_url": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/BA.svg", + "id": "arl_00001876aqC8c5umZmrRds", + "iata_code": "BA", + "conditions_of_carriage_url": "https://www.britishairways.com/en-gb/information/legal/british-airways/general-conditions-of-carriage" + }, + "live_mode": true, + "id": "off_00009htYpSCXrwaB9DnUm0", + "expires_at": "2020-01-17T10:42:14.545Z", + "created_at": "2020-01-17T10:12:14.545Z", + "conditions": { + "refund_before_departure": { + "penalty_currency": "GBP", + "penalty_amount": "100.00", + "allowed": true + }, + "change_before_departure": { + "penalty_currency": "GBP", + "penalty_amount": "100.00", + "allowed": true + } + }, + "base_currency": "GBP", + "base_amount": "30.20" + } + ], + "live_mode": false, + "id": "orq_00009hjdomFOCJyxHG7k7k", + "created_at": "2020-02-12T15:21:01.927Z", + "client_key": "SFMyNTY.g2gDdAAAAANkAAlsaXZlX21vZGVkAAVmYWxzZWQAD29yZ2FuaXNhdGlvbl9pZG0AAAAab3JnXzAwMDA5VWhGY29ERGk5TTFTRjhiS2FkAAtyZXNvdXJjZV9pZG0AAAAab3JxXzAwMDBBVkZWZnFJUXFBWXpYeVRRVlVuBgDpOCvdhwFiAAFRgA.df1RmLeBFUR7r1WFHHiEksilfSZNLhmPX0nj5VOKWJ4" + } + } \ No newline at end of file diff --git a/options.go b/options.go index 2f0774b..4783d09 100644 --- a/options.go +++ b/options.go @@ -4,7 +4,10 @@ package duffel -import "net/http" +import ( + "net/http" + "time" +) // WithAPIToken sets the API host to the default Duffel production host. func WithDefaultAPI() Option { @@ -49,3 +52,25 @@ func WithDebug() Option { c.Debug = true } } + +// WithRetry enables backoff retrying mechanism. If f retry function isn't provided +// ExponentalBackoff algorithm will be used. You should always use it in bound with WithRetryConditions options. +func WithRetry(maxAttempts int, minWaitTime, maxWaitTime time.Duration, f RetryFunc) Option { + return func(c *Options) { + c.Retry.MaxAttempts = maxAttempts + c.Retry.MinWaitTime = minWaitTime + c.Retry.MaxWaitTime = maxWaitTime + if f == nil { + f = ExponentalBackoff // used as default + } + c.Retry.Fn = f + } +} + +// WithRetryConditions appends retry condition. Retry functionality won't work +// without at least 1 retry condition. +func WithRetryCondition(condition RetryCond) Option { + return func(c *Options) { + c.Retry.Conditions = append(c.Retry.Conditions, condition) + } +} diff --git a/request.go b/request.go index 670e8f4..27cbd55 100644 --- a/request.go +++ b/request.go @@ -21,10 +21,20 @@ func newInternalClient[Req any, Resp any](a *API) *client[Req, Resp] { limiter: rate.NewLimiter(rate.Every(1*time.Second), 5), afterResponse: []func(resp *http.Response){ func(resp *http.Response) { - a.lastRequestID = resp.Header.Get(RequestIDHeader) + if resp != nil { + a.lastRequestID = resp.Header.Get(RequestIDHeader) + } }, }, } + if a.options.Retry.MaxAttempts != 0 { + client.retry = &backoff{ + minWaitTime: a.options.Retry.MinWaitTime, + maxWaitTime: a.options.Retry.MaxWaitTime, + maxAttempts: int32(a.options.Retry.MaxAttempts), + f: a.options.Retry.Fn, + } + } return client } @@ -57,9 +67,9 @@ func (c *client[Req, Resp]) Do(ctx context.Context, resourceName string, method c.rateLimit = rateLimit c.limiter.SetBurst(rateLimit.Limit) c.limiter.SetLimit(rate.Every(rateLimit.Period)) - if rateLimit.Remaining == 0 || resp.StatusCode == http.StatusTooManyRequests { return nil, fmt.Errorf("rate limit exceeded, reset in: %s, current limit: %d", rateLimit.Period.String(), rateLimit.Limit) } + return resp, nil } diff --git a/retry.go b/retry.go new file mode 100644 index 0000000..8e0f6e5 --- /dev/null +++ b/retry.go @@ -0,0 +1,54 @@ +package duffel + +import ( + "math" + "math/rand" + "net/http" + "sync/atomic" + "time" +) + +// RetryCond is a condition that applies only to retry backoff mechanism. +type RetryCond func(resp *http.Response, err error) bool + +// RetryFunc takes attemps number, minimal and maximal wait time for backoff. +// Returns duration that mechanism have to wait before making a request. +type RetryFunc func(n int, min, max time.Duration) time.Duration + +// backoff is a thread-safe retry backoff mechanism. +// Currently supported only ExponentalBackoff retry algorithm. +type backoff struct { + minWaitTime time.Duration + maxWaitTime time.Duration + maxAttempts int32 + attempts int32 + f RetryFunc +} + +const stopBackoff time.Duration = -1 + +func (b *backoff) next() time.Duration { + if atomic.LoadInt32(&b.attempts) >= b.maxAttempts { + return stopBackoff + } + atomic.AddInt32(&b.attempts, 1) + return b.f(int(atomic.LoadInt32(&b.attempts)), b.minWaitTime, b.maxWaitTime) +} + +func (b *backoff) reset() { + atomic.SwapInt32(&b.attempts, 0) +} + +func ExponentalBackoff(attemptNum int, min, max time.Duration) time.Duration { + const factor = 2.0 + rand.Seed(time.Now().UnixNano()) + delay := time.Duration(math.Pow(factor, float64(attemptNum)) * float64(min)) + jitter := time.Duration(rand.Float64() * float64(min) * float64(attemptNum)) + + delay = delay + jitter + if delay > max { + delay = max + } + + return delay +}