Skip to content
Open
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
11 changes: 10 additions & 1 deletion backend/pkg/providers/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strconv"
"time"

"github.com/sirupsen/logrus"
"github.com/vxcontrol/langchaingo/llms"
"github.com/vxcontrol/langchaingo/llms/reasoning"
)
Expand Down Expand Up @@ -80,9 +81,17 @@ func retryTransient[T any](ctx context.Context, delay time.Duration, call func()
return result, nil
case errors.Is(err, context.Canceled) || isDeterministicError(err):
return zero, err
case attempt == maxRetriesToCallSimpleChain:
}

logger := logrus.WithError(err).WithFields(logrus.Fields{
"attempt": attempt,
"max_attempts": maxRetriesToCallSimpleChain,
})
if attempt == maxRetriesToCallSimpleChain {
logger.Warn("call failed, no attempts left")
return zero, fmt.Errorf("gave up after %d attempts: %w", attempt, err)
}
logger.Warn("call failed, will retry")

select {
case <-ctx.Done():
Expand Down
58 changes: 58 additions & 0 deletions backend/pkg/providers/retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"pentagi/pkg/providers/pconfig"
"pentagi/pkg/providers/provider"

"github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vxcontrol/langchaingo/llms"
Expand Down Expand Up @@ -240,3 +242,59 @@ func TestRetry_RetryTransient_SetupCallIsNotAskedAgainAfterALocalRefusal(t *test
assert.ErrorAs(t, err, new(*llms.ErrStructuredOutputUnsupported))
assert.Equal(t, 1, prv.callCount, "a refusal the library decided locally must not be asked again")
}

func TestRetry_RetryTransient_LogsEachFailedAttempt(t *testing.T) {
hook := logtest.NewGlobal()
t.Cleanup(hook.Reset)

transient := errors.New("dial tcp 10.255.255.1:443: i/o timeout")
tests := []struct {
name string
errs []error
wantLogs []string
}{
{
name: "every attempt fails",
errs: []error{transient},
wantLogs: []string{"call failed, will retry", "call failed, will retry", "call failed, no attempts left"},
},
{
name: "the second attempt succeeds",
errs: []error{transient, nil},
wantLogs: []string{"call failed, will retry"},
},
{
name: "a deterministic failure is not retried, so nothing is logged here",
errs: []error{&reasoning.ErrThinkingRequiresStream{Model: "qwen3.6-35b-a3b"}},
},
{
name: "a canceled call is not logged as a failure",
errs: []error{context.Canceled},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hook.Reset()

calls := 0
_, _ = retryTransient(context.Background(), time.Millisecond, func() (string, error) {
calls++
return "ok", tt.errs[min(calls, len(tt.errs))-1]
})

var got []string
for i, entry := range hook.AllEntries() {
if _, ok := entry.Data["max_attempts"]; !ok {
continue
}
got = append(got, entry.Message)
assert.Equal(t, logrus.WarnLevel, entry.Level)
assert.Equal(t, len(got), entry.Data["attempt"], "entry %d", i)
assert.Equal(t, maxRetriesToCallSimpleChain, entry.Data["max_attempts"])
assert.Equal(t, transient, entry.Data[logrus.ErrorKey])
}
assert.Equal(t, tt.wantLogs, got)
})
}
}
21 changes: 21 additions & 0 deletions backend/pkg/system/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/http"
"net/url"
"os"
Expand All @@ -17,6 +18,15 @@ const (
defaultHTTPClientTimeout = 10 * time.Minute
)

// Without them an unreachable host holds every attempt for the OS SYN timeout
// (about two minutes on Linux). They bound connecting only: a slow model answer
// is still limited by HTTPClientTimeout alone.
var (
httpDialTimeout = 30 * time.Second
httpDialKeepAlive = 30 * time.Second
httpTLSHandshakeTimeout = 10 * time.Second
)

func getHostname() string {
hn, err := os.Hostname()
if err != nil {
Expand Down Expand Up @@ -73,6 +83,8 @@ func GetHTTPClient(cfg *config.Config) (*http.Client, error) {
Proxy: func(req *http.Request) (*url.URL, error) {
return url.Parse(cfg.ProxyURL)
},
DialContext: newHTTPDialer().DialContext,
TLSHandshakeTimeout: httpTLSHandshakeTimeout,
TLSClientConfig: &tls.Config{
RootCAs: rootCAPool,
InsecureSkipVerify: cfg.ExternalSSLInsecure,
Expand All @@ -83,6 +95,8 @@ func GetHTTPClient(cfg *config.Config) (*http.Client, error) {
httpClient = &http.Client{
Timeout: timeout,
Transport: &http.Transport{
DialContext: newHTTPDialer().DialContext,
TLSHandshakeTimeout: httpTLSHandshakeTimeout,
TLSClientConfig: &tls.Config{
RootCAs: rootCAPool,
InsecureSkipVerify: cfg.ExternalSSLInsecure,
Expand All @@ -93,3 +107,10 @@ func GetHTTPClient(cfg *config.Config) (*http.Client, error) {

return httpClient, nil
}

func newHTTPDialer() *net.Dialer {
return &net.Dialer{
Timeout: httpDialTimeout,
KeepAlive: httpDialKeepAlive,
}
}
127 changes: 127 additions & 0 deletions backend/pkg/system/utils_timeout_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package system

import (
"context"
"errors"
"fmt"
"net"
"net/http"
"syscall"
"testing"
"time"
)

// blackholeAddr returns a local address that never completes a TCP handshake: a
// listener with a zero backlog that never accepts holds one connection in its
// queue and Linux silently drops every SYN after it, as an unreachable host does.
func blackholeAddr(t *testing.T) string {
t.Helper()

fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
t.Fatalf("failed to create socket: %v", err)
}
t.Cleanup(func() { syscall.Close(fd) })

if err := syscall.Bind(fd, &syscall.SockaddrInet4{Addr: [4]byte{127, 0, 0, 1}}); err != nil {
t.Fatalf("failed to bind: %v", err)
}
if err := syscall.Listen(fd, 0); err != nil {
t.Fatalf("failed to listen: %v", err)
}
sa, err := syscall.Getsockname(fd)
if err != nil {
t.Fatalf("failed to read the bound address: %v", err)
}
addr := fmt.Sprintf("127.0.0.1:%d", sa.(*syscall.SockaddrInet4).Port)

filler, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
t.Fatalf("failed to fill the accept queue: %v", err)
}
t.Cleanup(func() { filler.Close() })

return addr
}

func TestGetHTTPClient_BlackholedHostFailsWithinTheDialTimeout(t *testing.T) {
shortenConnectTimeouts(t, 300*time.Millisecond, time.Second)

tests := []struct {
name string
target func(addr string) (url, proxy string)
}{
{
name: "unreachable endpoint",
target: func(addr string) (string, string) { return "https://" + addr + "/v1/chat/completions", "" },
},
{
name: "unreachable proxy",
target: func(addr string) (string, string) {
return "https://api.openai.com/v1/chat/completions", "http://" + addr
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url, proxyURL := tt.target(blackholeAddr(t))
client, err := GetHTTPClient(createTestConfig("", false, proxyURL))
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

// The guard only keeps a missing bound from hanging the test for minutes.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
t.Fatalf("failed to build request: %v", err)
}

start := time.Now()
resp, err := client.Do(req)
elapsed := time.Since(start)
if err == nil {
resp.Body.Close()
t.Fatal("expected the dial to fail")
}

var netErr net.Error
if ctx.Err() != nil || !errors.As(err, &netErr) || !netErr.Timeout() {
t.Errorf("expected the dial itself to time out before the guard, got: %v", err)
}
if elapsed > 2*time.Second {
t.Errorf("expected to give up within the dial timeout, took %v", elapsed)
}
})
}
}

func TestGetHTTPClient_CancelDuringDialReturnsAtOnce(t *testing.T) {
client, err := GetHTTPClient(createTestConfig("", false, ""))
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(100*time.Millisecond, cancel)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+blackholeAddr(t), nil)
if err != nil {
t.Fatalf("failed to build request: %v", err)
}

start := time.Now()
resp, err := client.Do(req)
elapsed := time.Since(start)
if err == nil {
resp.Body.Close()
t.Fatal("expected the request to be canceled")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got: %v", err)
}
if elapsed > 2*time.Second {
t.Errorf("expected the cancel to stop the dial, took %v", elapsed)
}
}
Loading