Merge pull request #22 from adamdottv/adam/retries

fix(anthropic): better 429/529 handling
This commit is contained in:
Kujtim Hoxha 2025-04-08 20:32:57 +02:00 committed by GitHub
commit fde04bbf85
10 changed files with 312 additions and 157 deletions

View file

@ -107,6 +107,16 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
wg.Done() wg.Done()
}() }()
} }
{
sub := app.Status.Subscribe(ctx)
wg.Add(1)
go func() {
for ev := range sub {
ch <- ev
}
wg.Done()
}()
}
return ch, func() { return ch, func() {
cancel() cancel()
wg.Wait() wg.Wait()

View file

@ -11,7 +11,9 @@ import (
"github.com/kujtimiihoxha/termai/internal/lsp/watcher" "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
"github.com/kujtimiihoxha/termai/internal/message" "github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/permission" "github.com/kujtimiihoxha/termai/internal/permission"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session" "github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/util"
) )
type App struct { type App struct {
@ -25,6 +27,7 @@ type App struct {
Logger logging.Interface Logger logging.Interface
Status *pubsub.Broker[util.InfoMsg]
ceanups []func() ceanups []func()
} }
@ -43,6 +46,7 @@ func New(ctx context.Context, conn *sql.DB) *App {
Messages: messages, Messages: messages,
Permissions: permission.NewPermissionService(), Permissions: permission.NewPermissionService(),
Logger: log, Logger: log,
Status: pubsub.NewBroker[util.InfoMsg](),
LSPClients: make(map[string]*lsp.Client), LSPClients: make(map[string]*lsp.Client),
} }

View file

@ -15,6 +15,8 @@ import (
"github.com/kujtimiihoxha/termai/internal/llm/provider" "github.com/kujtimiihoxha/termai/internal/llm/provider"
"github.com/kujtimiihoxha/termai/internal/llm/tools" "github.com/kujtimiihoxha/termai/internal/llm/tools"
"github.com/kujtimiihoxha/termai/internal/message" "github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/tui/util"
) )
type Agent interface { type Agent interface {
@ -92,9 +94,24 @@ func (c *agent) processEvent(
assistantMsg.AppendContent(event.Content) assistantMsg.AppendContent(event.Content)
return c.Messages.Update(*assistantMsg) return c.Messages.Update(*assistantMsg)
case provider.EventError: case provider.EventError:
// TODO: remove when realease
log.Println("error", event.Error) log.Println("error", event.Error)
c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
Type: util.InfoTypeError,
Msg: event.Error.Error(),
})
return event.Error return event.Error
case provider.EventWarning:
c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
Type: util.InfoTypeWarn,
Msg: event.Info,
})
return nil
case provider.EventInfo:
c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
Type: util.InfoTypeInfo,
Msg: event.Info,
})
case provider.EventComplete: case provider.EventComplete:
assistantMsg.SetToolCalls(event.Response.ToolCalls) assistantMsg.SetToolCalls(event.Response.ToolCalls)
assistantMsg.AddFinish(event.Response.FinishReason) assistantMsg.AddFinish(event.Response.FinishReason)

View file

@ -4,7 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"time"
"github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/option"
@ -68,21 +70,24 @@ func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message
anthropicMessages := a.convertToAnthropicMessages(messages) anthropicMessages := a.convertToAnthropicMessages(messages)
anthropicTools := a.convertToAnthropicTools(tools) anthropicTools := a.convertToAnthropicTools(tools)
response, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ response, err := a.client.Messages.New(
Model: anthropic.Model(a.model.APIModel), ctx,
MaxTokens: a.maxTokens, anthropic.MessageNewParams{
Temperature: anthropic.Float(0), Model: anthropic.Model(a.model.APIModel),
Messages: anthropicMessages, MaxTokens: a.maxTokens,
Tools: anthropicTools, Temperature: anthropic.Float(0),
System: []anthropic.TextBlockParam{ Messages: anthropicMessages,
{ Tools: anthropicTools,
Text: a.systemMessage, System: []anthropic.TextBlockParam{
CacheControl: anthropic.CacheControlEphemeralParam{ {
Type: "ephemeral", Text: a.systemMessage,
CacheControl: anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
},
}, },
}, },
}, },
}) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -121,84 +126,172 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
temperature = anthropic.Float(1) temperature = anthropic.Float(1)
} }
stream := a.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
Model: anthropic.Model(a.model.APIModel),
MaxTokens: a.maxTokens,
Temperature: temperature,
Messages: anthropicMessages,
Tools: anthropicTools,
Thinking: thinkingParam,
System: []anthropic.TextBlockParam{
{
Text: a.systemMessage,
CacheControl: anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
},
},
},
})
eventChan := make(chan ProviderEvent) eventChan := make(chan ProviderEvent)
go func() { go func() {
defer close(eventChan) defer close(eventChan)
accumulatedMessage := anthropic.Message{} const maxRetries = 8
attempts := 0
for stream.Next() { for {
event := stream.Current() // If this isn't the first attempt, we're retrying
err := accumulatedMessage.Accumulate(event) if attempts > 0 {
if err != nil { if attempts > maxRetries {
eventChan <- ProviderEvent{Type: EventError, Error: err} eventChan <- ProviderEvent{
Type: EventError,
Error: errors.New("maximum retry attempts reached for rate limit (429)"),
}
return
}
// Inform user we're retrying with attempt number
eventChan <- ProviderEvent{
Type: EventWarning,
Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries),
}
// Calculate backoff with exponential backoff and jitter
backoffMs := 2000 * (1 << (attempts - 1)) // 2s, 4s, 8s, 16s, 32s
jitterMs := int(float64(backoffMs) * 0.2)
totalBackoffMs := backoffMs + jitterMs
// Sleep with backoff, respecting context cancellation
select {
case <-ctx.Done():
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
return
case <-time.After(time.Duration(totalBackoffMs) * time.Millisecond):
// Continue with retry
}
}
attempts++
// Create new streaming request
stream := a.client.Messages.NewStreaming(
ctx,
anthropic.MessageNewParams{
Model: anthropic.Model(a.model.APIModel),
MaxTokens: a.maxTokens,
Temperature: temperature,
Messages: anthropicMessages,
Tools: anthropicTools,
Thinking: thinkingParam,
System: []anthropic.TextBlockParam{
{
Text: a.systemMessage,
CacheControl: anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
},
},
},
},
)
// Process stream events
accumulatedMessage := anthropic.Message{}
streamSuccess := false
// Process the stream until completion or error
for stream.Next() {
event := stream.Current()
err := accumulatedMessage.Accumulate(event)
if err != nil {
eventChan <- ProviderEvent{Type: EventError, Error: err}
return // Don't retry on accumulation errors
}
switch event := event.AsAny().(type) {
case anthropic.ContentBlockStartEvent:
eventChan <- ProviderEvent{Type: EventContentStart}
case anthropic.ContentBlockDeltaEvent:
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
eventChan <- ProviderEvent{
Type: EventThinkingDelta,
Thinking: event.Delta.Thinking,
}
} else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
eventChan <- ProviderEvent{
Type: EventContentDelta,
Content: event.Delta.Text,
}
}
case anthropic.ContentBlockStopEvent:
eventChan <- ProviderEvent{Type: EventContentStop}
case anthropic.MessageStopEvent:
streamSuccess = true
content := ""
for _, block := range accumulatedMessage.Content {
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
content += text.Text
}
}
toolCalls := a.extractToolCalls(accumulatedMessage.Content)
tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: tokenUsage,
FinishReason: string(accumulatedMessage.StopReason),
},
}
}
}
// If the stream completed successfully, we're done
if streamSuccess {
return return
} }
switch event := event.AsAny().(type) { // Check for stream errors
case anthropic.ContentBlockStartEvent: err := stream.Err()
eventChan <- ProviderEvent{Type: EventContentStart} if err != nil {
var apierr *anthropic.Error
if errors.As(err, &apierr) {
if apierr.StatusCode == 429 || apierr.StatusCode == 529 {
// Check for Retry-After header
if retryAfterValues := apierr.Response.Header.Values("Retry-After"); len(retryAfterValues) > 0 {
// Parse the retry after value (seconds)
var retryAfterSec int
if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
retryMs := retryAfterSec * 1000
case anthropic.ContentBlockDeltaEvent: // Inform user of retry with specific wait time
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" { eventChan <- ProviderEvent{
eventChan <- ProviderEvent{ Type: EventWarning,
Type: EventThinkingDelta, Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
Thinking: event.Delta.Thinking, }
}
} else if event.Delta.Type == "text_delta" && event.Delta.Text != "" { // Sleep respecting context cancellation
eventChan <- ProviderEvent{ select {
Type: EventContentDelta, case <-ctx.Done():
Content: event.Delta.Text, eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
return
case <-time.After(time.Duration(retryMs) * time.Millisecond):
// Continue with retry after specified delay
continue
}
}
}
// Fall back to exponential backoff if Retry-After parsing failed
continue
} }
} }
case anthropic.ContentBlockStopEvent: // For non-rate limit errors, report and exit
eventChan <- ProviderEvent{Type: EventContentStop} eventChan <- ProviderEvent{Type: EventError, Error: err}
return
case anthropic.MessageStopEvent:
content := ""
for _, block := range accumulatedMessage.Content {
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
content += text.Text
}
}
toolCalls := a.extractToolCalls(accumulatedMessage.Content)
tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: tokenUsage,
FinishReason: string(accumulatedMessage.StopReason),
},
}
} }
} }
if stream.Err() != nil {
eventChan <- ProviderEvent{Type: EventError, Error: stream.Err()}
}
}() }()
return eventChan, nil return eventChan, nil
@ -311,3 +404,4 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
return anthropicMessages return anthropicMessages
} }

View file

@ -17,6 +17,8 @@ const (
EventContentStop EventType = "content_stop" EventContentStop EventType = "content_stop"
EventComplete EventType = "complete" EventComplete EventType = "complete"
EventError EventType = "error" EventError EventType = "error"
EventWarning EventType = "warning"
EventInfo EventType = "info"
) )
type TokenUsage struct { type TokenUsage struct {
@ -40,6 +42,9 @@ type ProviderEvent struct {
ToolCall *message.ToolCall ToolCall *message.ToolCall
Error error Error error
Response *ProviderResponse Response *ProviderResponse
// Used for giving users info on e.x retry
Info string
} }
type Provider interface { type Provider interface {

View file

@ -7,16 +7,16 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/config" "github.com/kujtimiihoxha/termai/internal/config"
"github.com/kujtimiihoxha/termai/internal/llm/models" "github.com/kujtimiihoxha/termai/internal/llm/models"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/tui/styles" "github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util" "github.com/kujtimiihoxha/termai/internal/tui/util"
"github.com/kujtimiihoxha/termai/internal/version" "github.com/kujtimiihoxha/termai/internal/version"
) )
type statusCmp struct { type statusCmp struct {
err error info *util.InfoMsg
info string width int
width int messageTTL time.Duration
messageTTL time.Duration
} }
// clearMessageCmd is a command that clears status messages after a timeout // clearMessageCmd is a command that clears status messages after a timeout
@ -34,17 +34,15 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
case util.ErrorMsg: return m, m.clearMessageCmd()
m.err = msg case pubsub.Event[util.InfoMsg]:
m.info = "" m.info = &msg.Payload
return m, m.clearMessageCmd() return m, m.clearMessageCmd()
case util.InfoMsg: case util.InfoMsg:
m.info = string(msg) m.info = &msg
m.err = nil
return m, m.clearMessageCmd() return m, m.clearMessageCmd()
case util.ClearStatusMsg: case util.ClearStatusMsg:
m.info = "" m.info = nil
m.err = nil
} }
return m, nil return m, nil
} }
@ -56,25 +54,25 @@ var (
func (m statusCmp) View() string { func (m statusCmp) View() string {
status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help") status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
if m.info != nil {
if m.err != nil { infoStyle := styles.Padded.
status += styles.Regular.Padding(0, 1).
Background(styles.Red).
Foreground(styles.Text).
Width(m.availableFooterMsgWidth()).
Render(m.err.Error())
} else if m.info != "" {
status += styles.Padded.
Foreground(styles.Base). Foreground(styles.Base).
Background(styles.Green). Width(m.availableFooterMsgWidth())
Width(m.availableFooterMsgWidth()). switch m.info.Type {
Render(m.info) case util.InfoTypeInfo:
infoStyle = infoStyle.Background(styles.Blue)
case util.InfoTypeWarn:
infoStyle = infoStyle.Background(styles.Peach)
case util.InfoTypeError:
infoStyle = infoStyle.Background(styles.Red)
}
status += infoStyle.Render(m.info.Msg)
} else { } else {
status += styles.Padded. status += styles.Padded.
Foreground(styles.Base). Foreground(styles.Base).
Background(styles.LightGrey). Background(styles.LightGrey).
Width(m.availableFooterMsgWidth()). Width(m.availableFooterMsgWidth()).
Render(m.info) Render("")
} }
status += m.model() status += m.model()
status += versionWidget status += versionWidget
@ -93,6 +91,6 @@ func (m statusCmp) model() string {
func NewStatusCmp() tea.Model { func NewStatusCmp() tea.Model {
return &statusCmp{ return &statusCmp{
messageTTL: 5 * time.Second, messageTTL: 15 * time.Second,
} }
} }

View file

@ -112,13 +112,13 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.selectOption.Blur() p.selectOption.Blur()
// Add a visual indicator for focus change // Add a visual indicator for focus change
cmds = append(cmds, tea.Batch( cmds = append(cmds, tea.Batch(
util.CmdHandler(util.InfoMsg("Viewing content - use arrow keys to scroll")), util.ReportInfo("Viewing content - use arrow keys to scroll"),
)) ))
} else { } else {
p.selectOption.Focus() p.selectOption.Focus()
// Add a visual indicator for focus change // Add a visual indicator for focus change
cmds = append(cmds, tea.Batch( cmds = append(cmds, tea.Batch(
util.CmdHandler(util.InfoMsg("Select an action")), util.CmdHandler(util.ReportInfo("Select an action")),
)) ))
} }
return p, tea.Batch(cmds...) return p, tea.Batch(cmds...)

View file

@ -140,7 +140,7 @@ func (m *editorCmp) Send() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
messages, _ := m.app.Messages.List(m.sessionID) messages, _ := m.app.Messages.List(m.sessionID)
if hasUnfinishedMessages(messages) { if hasUnfinishedMessages(messages) {
return util.InfoMsg("Assistant is still working on the previous message") return util.ReportWarn("Assistant is still working on the previous message")
} }
a, _ := agent.NewCoderAgent(m.app) a, _ := agent.NewCoderAgent(m.app)

View file

@ -77,6 +77,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case pubsub.Event[permission.PermissionRequest]: case pubsub.Event[permission.PermissionRequest]:
return a, dialog.NewPermissionDialogCmd(msg.Payload) return a, dialog.NewPermissionDialogCmd(msg.Payload)
case pubsub.Event[util.InfoMsg]:
a.status, _ = a.status.Update(msg)
case dialog.PermissionResponseMsg: case dialog.PermissionResponseMsg:
switch msg.Action { switch msg.Action {
case dialog.PermissionAllow: case dialog.PermissionAllow:
@ -121,8 +123,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, a.moveToPage(msg.ID) return a, a.moveToPage(msg.ID)
case util.InfoMsg: case util.InfoMsg:
a.status, _ = a.status.Update(msg) a.status, _ = a.status.Update(msg)
case util.ErrorMsg:
a.status, _ = a.status.Update(msg)
case tea.KeyMsg: case tea.KeyMsg:
if a.editorMode == vimtea.ModeNormal { if a.editorMode == vimtea.ModeNormal {
switch { switch {
@ -141,7 +141,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.currentPage == page.ReplPage { if a.currentPage == page.ReplPage {
sessions, err := a.app.Sessions.List() sessions, err := a.app.Sessions.List()
if err != nil { if err != nil {
return a, util.CmdHandler(util.ErrorMsg(err)) return a, util.CmdHandler(util.ReportError(err))
} }
lastSession := sessions[0] lastSession := sessions[0]
if lastSession.MessageCount == 0 { if lastSession.MessageCount == 0 {
@ -149,7 +149,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
s, err := a.app.Sessions.Create("New Session") s, err := a.app.Sessions.Create("New Session")
if err != nil { if err != nil {
return a, util.CmdHandler(util.ErrorMsg(err)) return a, util.CmdHandler(util.ReportError(err))
} }
return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID}) return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
} }

View file

@ -9,12 +9,39 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
} }
func ReportError(err error) tea.Cmd { func ReportError(err error) tea.Cmd {
return CmdHandler(ErrorMsg(err)) return CmdHandler(InfoMsg{
Type: InfoTypeError,
Msg: err.Error(),
})
}
type InfoType int
const (
InfoTypeInfo InfoType = iota
InfoTypeWarn
InfoTypeError
)
func ReportInfo(info string) tea.Cmd {
return CmdHandler(InfoMsg{
Type: InfoTypeInfo,
Msg: info,
})
}
func ReportWarn(warn string) tea.Cmd {
return CmdHandler(InfoMsg{
Type: InfoTypeWarn,
Msg: warn,
})
} }
type ( type (
InfoMsg string InfoMsg struct {
ErrorMsg error Type InfoType
Msg string
}
ClearStatusMsg struct{} ClearStatusMsg struct{}
) )