Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
bin/
vendor/
.DS_Store
coverage.out
91 changes: 91 additions & 0 deletions client/ym/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
const defaultBaseURL = "https://botapi.messenger.yandex.net"

// HttpDoer is an interface for executing HTTP requests, typically satisfied by *http.Client.
// Implementations must be safe for concurrent use if the Client is shared across goroutines.
type HttpDoer interface {
Do(*http.Request) (*http.Response, error)
}
Expand Down Expand Up @@ -57,6 +58,7 @@ func NewClientWithHTTP(cfg Config, httpClient HttpDoer) *Client {

// DoRequest sends an HTTP request to the Yandex Messenger API with automatic
// retry and rate-limit handling according to the client configuration.
// On success (2xx), the caller is responsible for closing the returned response body.
func (c *Client) DoRequest(ctx context.Context, method, path string, body any) (*http.Response, error) {
var payload []byte
var err error
Expand Down Expand Up @@ -146,6 +148,84 @@ func (c *Client) DoRequest(ctx context.Context, method, path string, body any) (
return nil, fmt.Errorf("yandex-messenger/client: retries exhausted for %s %s", method, path)
}

// DoMultipartRequest sends an HTTP request with a pre-built body and content type,
// applying the same retry and rate-limit logic as DoRequest.
// On success (2xx), the caller is responsible for closing the returned response body.
func (c *Client) DoMultipartRequest(ctx context.Context, method, path, contentType string, body []byte) (*http.Response, error) {
url := strings.TrimRight(c.cfg.BaseURL, "/") + path
retryCfg := c.cfg.ErrorHandling.RetryStrategy
rateCfg := c.cfg.ErrorHandling.RateLimitHandling

attempts := retryCfg.MaxAttempts
if attempts < 1 {
attempts = 1
}
backoff := retryCfg.InitialBackoff
if backoff <= 0 {
backoff = 500 * time.Millisecond
}

for attempt := 1; attempt <= attempts; attempt++ {
req, reqErr := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if reqErr != nil {
return nil, fmt.Errorf("yandex-messenger/client: build request: %w", reqErr)
}
if c.cfg.Token != "" {
req.Header.Set("Authorization", "OAuth "+c.cfg.Token)
}
req.Header.Set("Content-Type", contentType)

resp, doErr := c.http.Do(req)
if doErr != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, fmt.Errorf("yandex-messenger/client: %w for %s %s", ctxErr, method, path)
}
var netErr net.Error
if errors.As(doErr, &netErr) && retryCfg.RetryNetwork && attempt < attempts {
time.Sleep(backoff)
backoff = NextBackoff(backoff, retryCfg.MaxBackoff)

continue
}

return nil, fmt.Errorf("yandex-messenger/client: %w for %s %s", doErr, method, path)
}

if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil
}

apiErr, parseErr := c.newAPIError(method, path, resp)
if parseErr != nil {
return nil, parseErr
}

if apiErr.Kind == ymerrors.KindRateLimited && attempt < attempts {
sleep := rateCfg.DefaultBackoff
if rateCfg.UseRetryAfter && apiErr.RetryAfter > 0 {
sleep = apiErr.RetryAfter
}
if sleep <= 0 {
sleep = rateCfg.DefaultBackoff
}
time.Sleep(sleep)

continue
}

if ShouldRetryHTTP(apiErr.HTTPStatus, retryCfg.RetryHTTP) && attempt < attempts {
time.Sleep(backoff)
backoff = NextBackoff(backoff, retryCfg.MaxBackoff)

continue
}

return nil, apiErr
}

return nil, fmt.Errorf("yandex-messenger/client: retries exhausted for %s %s", method, path)
}

func (c *Client) newAPIError(method, path string, resp *http.Response) (*ymerrors.APIError, error) {
defer resp.Body.Close()

Expand All @@ -171,6 +251,12 @@ func (c *Client) newAPIError(method, path string, resp *http.Response) (*ymerror
kind = ymerrors.KindInvalidToken
case http.StatusBadRequest:
kind = ymerrors.KindBadRequest
case http.StatusNotFound:
kind = ymerrors.KindNotFound
case http.StatusConflict:
kind = ymerrors.KindConflict
case http.StatusRequestEntityTooLarge:
kind = ymerrors.KindPayloadTooLarge
default:
if resp.StatusCode >= 500 {
kind = ymerrors.KindNetwork
Expand Down Expand Up @@ -282,6 +368,11 @@ func parseRetryAfter(value string) time.Duration {
if secs, err := strconv.Atoi(value); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
if t, err := time.Parse(time.RFC1123, value); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}

return 0
}
Expand Down
51 changes: 51 additions & 0 deletions client/ym/client_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,57 @@ func TestNewAPIErrorInvalidToken(t *testing.T) {
}
}

func TestNewAPIErrorNotFound(t *testing.T) {
client := &Client{}
resp := &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":false,"description":"not found"}`)),
Header: http.Header{},
}

apiErr, err := client.newAPIError("GET", "/path", resp)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if apiErr.Kind != ymerrors.KindNotFound {
t.Fatalf("expected KindNotFound, got %v", apiErr.Kind)
}
}

func TestNewAPIErrorConflict(t *testing.T) {
client := &Client{}
resp := &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":false,"description":"conflict"}`)),
Header: http.Header{},
}

apiErr, err := client.newAPIError("POST", "/path", resp)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if apiErr.Kind != ymerrors.KindConflict {
t.Fatalf("expected KindConflict, got %v", apiErr.Kind)
}
}

func TestNewAPIErrorPayloadTooLarge(t *testing.T) {
client := &Client{}
resp := &http.Response{
StatusCode: http.StatusRequestEntityTooLarge,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":false,"description":"too large"}`)),
Header: http.Header{},
}

apiErr, err := client.newAPIError("POST", "/path", resp)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if apiErr.Kind != ymerrors.KindPayloadTooLarge {
t.Fatalf("expected KindPayloadTooLarge, got %v", apiErr.Kind)
}
}

func TestNewAPIErrorNoBody(t *testing.T) {
client := &Client{}
resp := &http.Response{
Expand Down
125 changes: 125 additions & 0 deletions client/ym/client_multipart_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package ym

import (
"bytes"
"context"
"io"
"net/http"
"testing"

"github.com/rekurt/ymsdk/client/ym/ymerrors"
"github.com/rekurt/ymsdk/internal/testutil"
)

func TestDoMultipartRequestSuccess(t *testing.T) {
doer := &testutil.FakeDoer{
Responses: []*http.Response{
{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":true,"message_id":1}`)),
Header: http.Header{},
},
},
}
client := NewClientWithHTTP(Config{
BaseURL: "http://example.com",
Token: "tok",
ErrorHandling: ymerrors.ErrorHandlingConfig{
RetryStrategy: ymerrors.RetryStrategy{MaxAttempts: 1},
},
}, doer)

resp, err := client.DoMultipartRequest(context.Background(), http.MethodPost, "/upload", "multipart/form-data; boundary=abc", []byte("payload"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}

req := doer.Requests[0]
if req.Header.Get("Content-Type") != "multipart/form-data; boundary=abc" {
t.Fatalf("unexpected content type: %s", req.Header.Get("Content-Type"))
}
if req.Header.Get("Authorization") != "OAuth tok" {
t.Fatalf("unexpected auth: %s", req.Header.Get("Authorization"))
}
}

func TestDoMultipartRequestRetryOn500(t *testing.T) {
doer := &testutil.FakeDoer{
Responses: []*http.Response{
{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":false}`)),
Header: http.Header{},
},
{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":true}`)),
Header: http.Header{},
},
},
}
client := NewClientWithHTTP(Config{
BaseURL: "http://example.com",
ErrorHandling: ymerrors.ErrorHandlingConfig{
RetryStrategy: ymerrors.RetryStrategy{
MaxAttempts: 2,
InitialBackoff: 1,
},
},
}, doer)

resp, err := client.DoMultipartRequest(context.Background(), http.MethodPost, "/upload", "multipart/form-data", []byte("data"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()

if doer.CallCount() != 2 {
t.Fatalf("expected 2 attempts, got %d", doer.CallCount())
}
}

func TestDoMultipartRequestRateLimitFallback(t *testing.T) {
doer := &testutil.FakeDoer{
Responses: []*http.Response{
{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":false}`)),
Header: http.Header{"Retry-After": []string{"0"}},
},
{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":true}`)),
Header: http.Header{},
},
},
}
client := NewClientWithHTTP(Config{
BaseURL: "http://example.com",
ErrorHandling: ymerrors.ErrorHandlingConfig{
RetryStrategy: ymerrors.RetryStrategy{
MaxAttempts: 2,
InitialBackoff: 1,
},
RateLimitHandling: ymerrors.RateLimitHandling{
UseRetryAfter: true,
DefaultBackoff: 1,
},
},
}, doer)

resp, err := client.DoMultipartRequest(context.Background(), http.MethodPost, "/upload", "multipart/form-data", []byte("data"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()

if doer.CallCount() != 2 {
t.Fatalf("expected 2 attempts (rate limit + retry), got %d", doer.CallCount())
}
}
Loading
Loading