Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(internal): add backoff retry mechanism #17

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@

# Dependency directories (remove the comment below to include it)
# vendor/
*.DS_store
examples/.DS_Store
45 changes: 45 additions & 0 deletions batchofferrequests.go
Original file line number Diff line number Diff line change
@@ -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)
}
76 changes: 76 additions & 0 deletions batchofferrequests_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
71 changes: 60 additions & 11 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"

"github.com/segmentio/encoding/json"
)
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ package duffel

import (
"context"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
Expand Down Expand Up @@ -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())
}
21 changes: 20 additions & 1 deletion duffel.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const defaultHost = "https://api.duffel.com/"
type (
Duffel interface {
OfferRequestClient
BatchOfferRequestClient
OfferClient
OrderClient
OrderChangeClient
Expand Down Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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 {
Expand All @@ -246,6 +264,7 @@ type (
options *Options
limiter *rate.Limiter
rateLimit *RateLimit
retry *backoff
afterResponse []func(resp *http.Response)
}

Expand Down
10 changes: 10 additions & 0 deletions fixtures/200-create-batch-offer-request.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading