From 8ac05239a9f5639c34c14bb67391970e2075fc42 Mon Sep 17 00:00:00 2001 From: Neur0toxine Date: Thu, 13 Nov 2025 17:07:09 +0300 Subject: [PATCH 1/2] better rate limit and cancellation --- README.md | 27 ++++++ client.go | 141 +++++++++++++++++---------- client_test.go | 57 +++++++---- go.mod | 10 +- go.sum | 3 +- rate.go | 58 ++++++++++++ rate_test.go | 251 +++++++++++++++++++++++++++++++++++++++++++++++++ types.go | 33 ++----- 8 files changed, 486 insertions(+), 94 deletions(-) create mode 100644 rate.go create mode 100644 rate_test.go diff --git a/README.md b/README.md index 9ebb481..c705419 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,33 @@ func main() { } ``` +## Rate limits + +This client can work with default rate limits but doesn't do that unless specified explicitly. You can enable default +rate limits like this: + +```go +client := retailcrm.New("https://demo.retailcrm.pro", "09jIJ09j0JKhgyfvyuUIKhiugF"). + EnableRateLimiter(0) +``` + +This `client` will automatically apply rate limiting. Requests will block until they are finished. You can also provide +different retry parameter instead of `0` if you don't want the client to block completely and would like to process +rate limit by yourself after several attempts. + +Custom rate limiter can be provided like this: + +```go +limiter := retailcrm.NewSingleKeyLimiter() +client := retailcrm.New("https://demo.retailcrm.pro", "09jIJ09j0JKhgyfvyuUIKhiugF"). + EnableCustomRateLimiter(limiter, 0) +``` + +You can use your own version of limiter by implementing `retailcrm.Limiter`. Also, any instance of +`retailcrm.Limiter` which implements `retailcrm.ResponseAware` interface will be able to read response for each +request attempt (`(*http.Response).Body` is not guaranteed to be accessible). This feature can be used to control +rate limits for distributed applications using the same key. + ## Upgrading Please check the [UPGRADING.md](UPGRADING.md) to learn how to upgrade to the new version. diff --git a/client.go b/client.go index 49cbb9e..c31c1b6 100644 --- a/client.go +++ b/client.go @@ -2,6 +2,7 @@ package retailcrm import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -32,46 +33,81 @@ func (c *Client) WithLogger(logger BasicLogger) *Client { return c } +// WithHTTPClient sets the provided HTTP client instance into the Client. +func (c *Client) WithHTTPClient(client *http.Client) *Client { + c.httpClient = client + return c +} + +// WithContext sets context.Context which, if cancelled, will terminate all active API calls. +func (c *Client) WithContext(ctx context.Context) *Client { + c.ctx = ctx + return c +} + // EnableRateLimiter activates rate limiting with specified retry attempts. func (c *Client) EnableRateLimiter(maxAttempts uint) *Client { c.mutex.Lock() defer c.mutex.Unlock() - c.limiter = &RateLimiter{ - maxAttempts: maxAttempts, - lastRequest: time.Now().Add(-time.Second), // Initialize to allow immediate first request. + c.limiter = NewSingleKeyLimiter() + c.maxAttempts = maxAttempts + + return c +} + +// EnableCustomRateLimiter activates rate limiting with custom limiter logic and specified retry attempts. +func (c *Client) EnableCustomRateLimiter(limiter Limiter, maxAttempts uint) *Client { + c.mutex.Lock() + defer c.mutex.Unlock() + + if limiter == nil { + c.limiter = nil + c.maxAttempts = 0 + + return c } + c.limiter = limiter + c.maxAttempts = maxAttempts + return c } // applyRateLimit applies rate limiting before sending a request. -func (c *Client) applyRateLimit(uri string) { +func (c *Client) applyRateLimit(uri string) error { + c.mutex.RLock() + defer c.mutex.RUnlock() + if c.limiter == nil { - return + return nil } - c.limiter.mutex.Lock() - defer c.limiter.mutex.Unlock() - - var delay time.Duration - if strings.HasPrefix(uri, "/telephony") { - delay = telephonyDelay - } else { - delay = regularDelay + ctx := c.ctx + if ctx == nil { + ctx = context.Background() } - elapsed := time.Since(c.limiter.lastRequest) - if elapsed < delay { - time.Sleep(delay - elapsed) + return c.limiter.Limit(ctx, uri, c.Key) +} + +// triggerResponseAwareLimiter sends *http.Response to the limiter for further reading (e.g. headers). +func (c *Client) triggerResponseAwareLimiter(resp *http.Response) { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if c.limiter == nil || resp == nil { + return } - c.limiter.lastRequest = time.Now() + if val, ok := c.limiter.(ResponseAware); ok { + val.ProcessResponse(resp) + } } func (c *Client) executeWithRetryBytes( uri string, - executeFunc func() (interface{}, int, error), + executeFunc func() (interface{}, *http.Response, int, error), ) ([]byte, int, error) { res, status, err := c.executeWithRetry(uri, executeFunc) if res == nil { @@ -82,7 +118,7 @@ func (c *Client) executeWithRetryBytes( func (c *Client) executeWithRetryReadCloser( uri string, - executeFunc func() (interface{}, int, error), + executeFunc func() (interface{}, *http.Response, int, error), ) (io.ReadCloser, int, error) { res, status, err := c.executeWithRetry(uri, executeFunc) if res == nil { @@ -94,19 +130,21 @@ func (c *Client) executeWithRetryReadCloser( // executeWithRetry executes a request with retry logic for rate limiting. func (c *Client) executeWithRetry( uri string, - executeFunc func() (interface{}, int, error), + executeFunc func() (interface{}, *http.Response, int, error), ) (interface{}, int, error) { if c.limiter == nil { - return executeFunc() + resp, _, st, err := executeFunc() + return resp, st, err } var ( res interface{} + httpResp *http.Response statusCode int err error lastAttempt bool attempt uint = 1 - maxAttempts = c.limiter.maxAttempts + maxAttempts = c.maxAttempts totalAttempts = "∞" infinite = maxAttempts == 0 ) @@ -123,17 +161,22 @@ func (c *Client) executeWithRetry( } for infinite || attempt <= maxAttempts { - c.applyRateLimit(uri) - res, statusCode, err = executeFunc() + if err := c.applyRateLimit(uri); err != nil { + return nil, 0, err + } + + res, httpResp, statusCode, err = executeFunc() + c.triggerResponseAwareLimiter(httpResp) lastAttempt = !infinite && attempt == maxAttempts + isRateLimited := statusCode == http.StatusServiceUnavailable || statusCode == http.StatusTooManyRequests // If rate limited on final attempt, set error to ErrRateLimited. Return results otherwise. - if statusCode == http.StatusServiceUnavailable && lastAttempt { + if isRateLimited && lastAttempt { return res, statusCode, ErrRateLimited } // If not rate limited or on final attempt, return result. - if statusCode != http.StatusServiceUnavailable || lastAttempt { + if !isRateLimited || lastAttempt { return res, statusCode, err } @@ -173,12 +216,12 @@ func (c *Client) GetRequest(urlWithParameters string, versioned ...bool) ([]byte uri := urlWithParameters - return c.executeWithRetryBytes(uri, func() (interface{}, int, error) { + return c.executeWithRetryBytes(uri, func() (interface{}, *http.Response, int, error) { var res []byte req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s", c.URL, prefix, urlWithParameters), nil) if err != nil { - return res, 0, err + return res, nil, 0, err } req.Header.Set("X-API-KEY", c.Key) @@ -189,30 +232,30 @@ func (c *Client) GetRequest(urlWithParameters string, versioned ...bool) ([]byte resp, err := c.httpClient.Do(req) if err != nil { - return res, 0, err + return res, resp, 0, err } if resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { - return res, resp.StatusCode, CreateGenericAPIError( + return res, resp, resp.StatusCode, CreateGenericAPIError( fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode)) } res, err = buildRawResponse(resp) if err != nil { - return res, 0, err + return res, resp, 0, err } if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { - return res, resp.StatusCode, CreateAPIError(res) + return res, resp, resp.StatusCode, CreateAPIError(res) } if c.Debug { c.writeLog("API Response: %s", res) } - return res, resp.StatusCode, nil + return res, resp, resp.StatusCode, nil }) } @@ -232,17 +275,17 @@ func (c *Client) PostRequest( prefix := "/api/v5" - return c.executeWithRetryBytes(uri, func() (interface{}, int, error) { + return c.executeWithRetryBytes(uri, func() (interface{}, *http.Response, int, error) { var res []byte reader, err := getReaderForPostData(postData) if err != nil { - return res, 0, err + return res, nil, 0, err } req, err := http.NewRequest("POST", fmt.Sprintf("%s%s%s", c.URL, prefix, uri), reader) if err != nil { - return res, 0, err + return res, nil, 0, err } req.Header.Set("Content-Type", contentType) @@ -254,30 +297,30 @@ func (c *Client) PostRequest( resp, err := c.httpClient.Do(req) if err != nil { - return res, 0, err + return res, resp, 0, err } if resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { - return res, resp.StatusCode, CreateGenericAPIError( + return res, resp, resp.StatusCode, CreateGenericAPIError( fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode)) } res, err = buildRawResponse(resp) if err != nil { - return res, 0, err + return res, resp, 0, err } if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { - return res, resp.StatusCode, CreateAPIError(res) + return res, resp, resp.StatusCode, CreateAPIError(res) } if c.Debug { c.writeLog("API Response: %s", res) } - return res, resp.StatusCode, nil + return res, resp, resp.StatusCode, nil }) } @@ -7200,10 +7243,10 @@ func (c *Client) GetOrderPlate(by, orderID, site string, plateID int) (io.ReadCl "site": {site}, }.Encode()) - return c.executeWithRetryReadCloser(requestURL, func() (interface{}, int, error) { + return c.executeWithRetryReadCloser(requestURL, func() (interface{}, *http.Response, int, error) { req, err := http.NewRequest("GET", requestURL, nil) if err != nil { - return nil, 0, err + return nil, nil, 0, err } req.Header.Set("X-API-KEY", c.Key) @@ -7215,11 +7258,11 @@ func (c *Client) GetOrderPlate(by, orderID, site string, plateID int) (io.ReadCl resp, err := c.httpClient.Do(req) if err != nil { - return nil, 0, err + return nil, resp, 0, err } if resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { - return nil, resp.StatusCode, CreateGenericAPIError( + return nil, resp, resp.StatusCode, CreateGenericAPIError( fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode)) } @@ -7229,17 +7272,17 @@ func (c *Client) GetOrderPlate(by, orderID, site string, plateID int) (io.ReadCl res, err := buildRawResponse(resp) if err != nil { - return nil, 0, err + return nil, resp, 0, err } - return nil, resp.StatusCode, CreateAPIError(res) + return nil, resp, resp.StatusCode, CreateAPIError(res) } if err != nil { - return nil, 0, err + return nil, resp, 0, err } - return resp.Body, resp.StatusCode, nil + return resp.Body, resp, resp.StatusCode, nil }) } diff --git a/client_test.go b/client_test.go index 734d8c7..1132dbd 100644 --- a/client_test.go +++ b/client_test.go @@ -74,6 +74,14 @@ func client() *Client { return c } +func clientReal() *Client { + transport := http.DefaultTransport + if transport != gock.NativeTransport { + transport = gock.NativeTransport + } + return client().WithHTTPClient(&http.Client{Transport: transport, Timeout: time.Second}) +} + func badurlclient() *Client { return New(badURL, os.Getenv("RETAILCRM_KEY")) } @@ -91,7 +99,7 @@ func TestGetRequestWithRateLimiter(t *testing.T) { t.Run("Basic 404 response", func(t *testing.T) { c := client() - defer gock.Off() + defer gock.OffAll() gock.New(crmURL). Get("/api/v5/fake-method"). @@ -105,9 +113,9 @@ func TestGetRequestWithRateLimiter(t *testing.T) { t.Run("Rate limiter respects configured RPS", func(t *testing.T) { c := client() - c.EnableRateLimiter(3) + c.EnableRateLimiter(1) - defer gock.Off() + defer gock.OffAll() numRequests := 5 @@ -120,24 +128,25 @@ func TestGetRequestWithRateLimiter(t *testing.T) { start := time.Now() for i := 0; i < numRequests; i++ { - _, _, err := c.GetRequest("/test-method") - if err != nil { - t.Fatalf("Request %d failed: %v", i, err) - } + _, status, err := c.GetRequest("/test-method") + require.NoErrorf(t, err, "Request %d failed: %v", i, err) + require.Equal(t, http.StatusOK, status, "Request %d returned non-200 status", i) } elapsed := time.Since(start) minExpectedTime := time.Duration(numRequests-1) * time.Second / 10 - assert.Truef(t, elapsed > minExpectedTime, + assert.GreaterOrEqualf(t, elapsed, minExpectedTime, "Rate limiter not working correctly. Expected minimum time %v, got %v", minExpectedTime, elapsed) + + assert.True(t, gock.IsDone(), "Not all mocked requests were consumed") }) t.Run("Rate limiter respects telephony endpoint RPS", func(t *testing.T) { c := client() - c.EnableRateLimiter(3) + c.EnableRateLimiter(1) - defer gock.Off() + defer gock.OffAll() numRequests := 5 @@ -151,17 +160,18 @@ func TestGetRequestWithRateLimiter(t *testing.T) { start := time.Now() for i := 0; i < numRequests; i++ { - _, _, err := c.GetRequest("/telephony/test-call") - if err != nil { - t.Fatalf("Request %d failed: %v", i, err) - } + _, status, err := c.GetRequest("/telephony/test-call") + require.NoErrorf(t, err, "Request %d failed: %v", i, err) + require.Equal(t, http.StatusOK, status, "Request %d returned non-200 status", i) } elapsed := time.Since(start) minExpectedTime := time.Duration(numRequests-1) * time.Second / 40 - assert.Truef(t, elapsed > minExpectedTime, + assert.GreaterOrEqualf(t, elapsed, minExpectedTime, "Rate limiter not working correctly for telephony. Expected minimum time %v, got %v", minExpectedTime, elapsed) + + assert.True(t, gock.IsDone(), "Not all mocked requests were consumed") }) t.Run("Rate limiter retries on 503 responses", func(t *testing.T) { @@ -169,7 +179,7 @@ func TestGetRequestWithRateLimiter(t *testing.T) { c.EnableRateLimiter(3) c.Debug = true - defer gock.Off() + defer gock.OffAll() gock.New(crmURL). Get("/api/v5/retry-test"). @@ -189,13 +199,13 @@ func TestGetRequestWithRateLimiter(t *testing.T) { _, status, err := c.GetRequest("/retry-test") require.NoErrorf(t, err, "Request failed despite retries: %v", err) - assert.Equal(t, http.StatusOK, status) + assert.Equal(t, http.StatusOK, status, "Expected successful response after retries") assert.True(t, gock.IsDone(), "Not all expected requests were made") }) t.Run("Rate limiter gives up after max attempts", func(t *testing.T) { c := client() - c.EnableRateLimiter(2) + c.EnableRateLimiter(3) defer gock.OffAll() @@ -208,9 +218,16 @@ func TestGetRequestWithRateLimiter(t *testing.T) { _, status, err := c.GetRequest("/retry-test") - assert.Equalf(t, http.StatusServiceUnavailable, status, + assert.Equal(t, http.StatusServiceUnavailable, status, "Expected status 503 after max retries, got %d", status) - assert.ErrorIs(t, err, ErrRateLimited, "Expected error after max retries, got nil") + assert.Error(t, err, "Expected error after max retries") + + if err != nil { + assert.Contains(t, err.Error(), "rate limit", + "Expected rate limit error, got: %v", err) + } + + assert.True(t, gock.IsDone(), "Not all mocked requests were consumed") }) } diff --git a/go.mod b/go.mod index 2d1f311..ef80249 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,18 @@ module github.com/retailcrm/api-client-go/v2 -go 1.13 +go 1.24.0 require ( github.com/google/go-querystring v1.1.0 github.com/joho/godotenv v1.3.0 github.com/stretchr/testify v1.7.0 + golang.org/x/time v0.14.0 gopkg.in/h2non/gock.v1 v1.1.2 ) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum index 0fc530c..1fd1de7 100644 --- a/go.sum +++ b/go.sum @@ -15,7 +15,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/rate.go b/rate.go new file mode 100644 index 0000000..fa4aafe --- /dev/null +++ b/rate.go @@ -0,0 +1,58 @@ +package retailcrm + +import ( + "context" + "net/http" + "strings" + "time" + + "golang.org/x/time/rate" +) + +const ( + // RPS for the single key. + RPS = 10 + // TelephonyRPS is used for telephony routes instead of RPS. + TelephonyRPS = 40 + regularDelay = time.Second / RPS // Delay between regular requests. + telephonyDelay = time.Second / TelephonyRPS // Delay between telephony requests. +) + +// Limiter describes basic rate limiter. +type Limiter interface { + // Limit the request. Returned error will be received from API method which is being called. + Limit(ctx context.Context, uri, key string) error +} + +// ResponseAware can be used to make rate limiter which will be able to read every response. +// Body is not guaranteed for this method and may be already read. +type ResponseAware interface { + ProcessResponse(resp *http.Response) +} + +// RateLimiter is the alias for SingleKeyLimiter. +// Deprecated: use SingleKeyLimiter and NewSingleKeyLimiter instead. +type RateLimiter SingleKeyLimiter + +// SingleKeyLimiter manages API request rates to prevent hitting rate limits. Works for only one key. +type SingleKeyLimiter struct { + regularLimiter *rate.Limiter + telephonyLimiter *rate.Limiter +} + +// NewSingleKeyLimiter instantiates new SingleKeyLimiter. +func NewSingleKeyLimiter() Limiter { + return &SingleKeyLimiter{ + regularLimiter: rate.NewLimiter(rate.Limit(RPS), 1), + telephonyLimiter: rate.NewLimiter(rate.Limit(TelephonyRPS), 1), + } +} + +// Limit the request. +func (r *SingleKeyLimiter) Limit(ctx context.Context, uri, _ string) error { + if strings.HasPrefix(uri, "/telephony") { + return r.telephonyLimiter.Wait(ctx) + } + + return r.regularLimiter.Wait(ctx) +} diff --git a/rate_test.go b/rate_test.go new file mode 100644 index 0000000..f1b3866 --- /dev/null +++ b/rate_test.go @@ -0,0 +1,251 @@ +package retailcrm + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSingleKeyLimiter(t *testing.T) { + limiter := NewSingleKeyLimiter() + require.NotNil(t, limiter, "NewSingleKeyLimiter returned nil") + + skl, ok := limiter.(*SingleKeyLimiter) + require.True(t, ok, "NewSingleKeyLimiter did not return *SingleKeyLimiter") + assert.NotNil(t, skl.regularLimiter, "regularLimiter should not be nil") + assert.NotNil(t, skl.telephonyLimiter, "telephonyLimiter should not be nil") +} + +func TestSingleKeyLimiter_Limit_RegularRoute(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + start := time.Now() + require.NoError(t, limiter.Limit(ctx, "/api/orders", "test-key")) + require.WithinDuration(t, start, time.Now(), 10*time.Millisecond, "First request should not be delayed") + + start = time.Now() + require.NoError(t, limiter.Limit(ctx, "/api/customers", "test-key")) + elapsed := time.Since(start) + + expectedDelay := regularDelay + tolerance := 50 * time.Millisecond + assert.GreaterOrEqual(t, elapsed, expectedDelay-tolerance, + "Request completed too quickly: %v (expected ~%v)", elapsed, expectedDelay) +} + +func TestSingleKeyLimiter_Limit_TelephonyRoute(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + start := time.Now() + require.NoError(t, limiter.Limit(ctx, "/telephony/calls", "test-key")) + require.WithinDuration(t, start, time.Now(), 10*time.Millisecond, + "First telephony request should not be delayed") + + start = time.Now() + require.NoError(t, limiter.Limit(ctx, "/telephony/status", "test-key")) + elapsed := time.Since(start) + + expectedDelay := telephonyDelay + tolerance := 20 * time.Millisecond + assert.GreaterOrEqual(t, elapsed, expectedDelay-tolerance, + "Telephony request completed too quickly: %v (expected ~%v)", elapsed, expectedDelay) +} + +func TestSingleKeyLimiter_Limit_SeparateLimiters(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + require.NoError(t, limiter.Limit(ctx, "/api/orders", "test-key")) + + start := time.Now() + require.NoError(t, limiter.Limit(ctx, "/telephony/calls", "test-key")) + elapsed := time.Since(start) + + assert.Less(t, elapsed, 50*time.Millisecond, + "Telephony request was delayed by regular limiter: %v", elapsed) +} + +func TestSingleKeyLimiter_Limit_ContextCancellation(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + require.NoError(t, limiter.Limit(context.Background(), "/api/test", "test-key")) + + err := limiter.Limit(ctx, "/api/test", "test-key") + assert.ErrorIs(t, err, context.Canceled, "Expected context.Canceled error") +} + +func TestSingleKeyLimiter_Limit_ContextTimeout(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + require.NoError(t, limiter.Limit(context.Background(), "/api/test", "test-key")) + + time.Sleep(5 * time.Millisecond) + + err := limiter.Limit(ctx, "/api/test", "test-key") + assert.Error(t, err, "Expected an error with timed out context") + assert.ErrorIs(t, err, context.DeadlineExceeded, + "Expected error to be or contain context.DeadlineExceeded") +} + +func TestSingleKeyLimiter_Limit_URIPrefixMatching(t *testing.T) { + tests := []struct { + name string + uri string + shouldUseTelephony bool + }{ + {"Exact telephony prefix", "/telephony", true}, + {"Telephony with path", "/telephony/calls/123", true}, + {"Regular route", "/api/orders", false}, + {"Similar but not telephony", "/telephone", false}, + {"Empty URI", "", false}, + {"Root path", "/", false}, + {"Telephony uppercase", "/TELEPHONY/calls", false}, // Case sensitive + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + require.NoError(t, limiter.Limit(ctx, tt.uri, "test-key")) + + start := time.Now() + require.NoError(t, limiter.Limit(ctx, tt.uri, "test-key")) + elapsed := time.Since(start) + + if tt.shouldUseTelephony { + assert.Less(t, elapsed, 40*time.Millisecond, + "Expected telephony rate limit (~25ms), got %v", elapsed) + } else { + assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond, + "Expected regular rate limit (~100ms), got %v", elapsed) + } + }) + } +} + +func TestSingleKeyLimiter_Limit_KeyIgnored(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + require.NoError(t, limiter.Limit(ctx, "/api/test", "key1")) + + start := time.Now() + require.NoError(t, limiter.Limit(ctx, "/api/test", "key2")) + elapsed := time.Since(start) + + assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond, + "Different keys should still share the same rate limit") +} + +func TestSingleKeyLimiter_Limit_Concurrent(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + results := make(chan time.Duration, 5) + start := time.Now() + + for i := 0; i < 5; i++ { + go func() { + reqStart := time.Now() + err := limiter.Limit(ctx, "/api/test", "test-key") + require.NoError(t, err, "Request should not fail") + results <- time.Since(reqStart) + }() + } + + for i := 0; i < 5; i++ { + <-results + } + totalElapsed := time.Since(start) + + expectedMin := 350 * time.Millisecond + expectedMax := 550 * time.Millisecond + + assert.GreaterOrEqual(t, totalElapsed, expectedMin, + "Concurrent requests completed too quickly: %v", totalElapsed) + assert.LessOrEqual(t, totalElapsed, expectedMax, + "Concurrent requests took too long: %v", totalElapsed) +} + +func TestSingleKeyLimiter_Limit_RateEnforcement(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + count := 5 + start := time.Now() + + for i := 0; i < count; i++ { + require.NoError(t, limiter.Limit(ctx, "/api/orders", "key1")) + } + + elapsed := time.Since(start) + expectedMin := regularDelay * time.Duration(count-1) + + assert.GreaterOrEqual(t, elapsed, expectedMin, + "Rate not enforced: expected at least %v, got %v", expectedMin, elapsed) +} + +func TestSingleKeyLimiter_Limit_TelephonyRateEnforcement(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx := context.Background() + + count := 5 + start := time.Now() + + for i := 0; i < count; i++ { + require.NoError(t, limiter.Limit(ctx, "/telephony/call", "key1")) + } + + elapsed := time.Since(start) + expectedMin := telephonyDelay * time.Duration(count-1) + + assert.GreaterOrEqual(t, elapsed, expectedMin, + "Telephony rate not enforced: expected at least %v, got %v", expectedMin, elapsed) +} + +func TestSingleKeyLimiter_Limit_CanReturnAnError(t *testing.T) { + limiter := NewSingleKeyLimiter().(*SingleKeyLimiter) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + assert.ErrorIs(t, limiter.Limit(ctx, "/telephony/call", "key1"), context.Canceled) +} + +func TestConstants(t *testing.T) { + assert.Equal(t, 10, RPS, "RPS should be 10") + assert.Equal(t, 40, TelephonyRPS, "TelephonyRPS should be 40") + assert.Equal(t, time.Second/RPS, regularDelay, "regularDelay should be 100ms") + assert.Equal(t, time.Second/TelephonyRPS, telephonyDelay, "telephonyDelay should be 25ms") +} + +func BenchmarkSingleKeyLimiter_Limit_Regular(b *testing.B) { + limiter := NewSingleKeyLimiter() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = limiter.Limit(ctx, "/api/test", "test-key") + } +} + +func BenchmarkSingleKeyLimiter_Limit_Telephony(b *testing.B) { + limiter := NewSingleKeyLimiter() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = limiter.Limit(ctx, "/telephony/test", "test-key") + } +} diff --git a/types.go b/types.go index bb7f075..ee1f96c 100644 --- a/types.go +++ b/types.go @@ -1,12 +1,12 @@ package retailcrm import ( + "context" "encoding/json" "net/http" "reflect" "strings" "sync" - "time" ) // ByID is "id" constant to use as `by` property in methods. @@ -15,34 +15,21 @@ const ByID = "id" // ByExternalID is "externalId" constant to use as `by` property in methods. const ByExternalID = "externalId" -// RateLimiter configuration constants. -const ( - regularPathRPS = 10 // API rate limit (requests per second). - telephonyPathRPS = 40 // Telephony API endpoints rate limit (requests per second). - regularDelay = time.Second / regularPathRPS // Delay between regular requests. - telephonyDelay = time.Second / telephonyPathRPS // Delay between telephony requests. -) - // HTTPStatusUnknown can return for the method `/api/v5/customers/upload`, `/api/v5/customers-corporate/upload`, // `/api/v5/orders/upload`. const HTTPStatusUnknown = 460 // Client type. type Client struct { - URL string - Key string - Debug bool - httpClient *http.Client - logger BasicLogger - limiter *RateLimiter - mutex sync.Mutex -} - -// RateLimiter manages API request rates to prevent hitting rate limits. -type RateLimiter struct { - maxAttempts uint // Maximum number of retry attempts (0 = infinite). - lastRequest time.Time // Time of the last request. - mutex sync.Mutex + ctx context.Context + URL string + Key string + Debug bool + httpClient *http.Client + logger BasicLogger + limiter Limiter + maxAttempts uint // Maximum number of retry attempts (0 = infinite). + mutex sync.RWMutex } // Pagination type. From fe4648e327a334d40782663a8583430de20abd6d Mon Sep 17 00:00:00 2001 From: Neur0toxine Date: Thu, 13 Nov 2025 17:13:21 +0300 Subject: [PATCH 2/2] return 1.19 support --- .tool-versions | 2 +- go.mod | 4 ++-- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.tool-versions b/.tool-versions index f0d2c8d..629f0f8 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -go 1.23.12 +go 1.19.13 gotestsum 1.13.0 golangci-lint 1.62.2 \ No newline at end of file diff --git a/go.mod b/go.mod index ef80249..a155306 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/retailcrm/api-client-go/v2 -go 1.24.0 +go 1.19 require ( github.com/google/go-querystring v1.1.0 github.com/joho/godotenv v1.3.0 github.com/stretchr/testify v1.7.0 - golang.org/x/time v0.14.0 + golang.org/x/time v0.10.0 gopkg.in/h2non/gock.v1 v1.1.2 ) diff --git a/go.sum b/go.sum index 1fd1de7..a1d9c03 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=