Skip to content
Draft
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
65 changes: 65 additions & 0 deletions docs/mcp_error_handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# MCP LLM Error Handling

When interacting with multiple AI Providers (Google, OpenAI, Anthropic) via `langchaingo`, LLM calls can fail in messy, unpredictable ways. Providers may return HTTP 503s for overload, 429s for rate limits, or 401s for revoked keys.

If we return these raw errors directly to the MCP client (the chat UI), the turn crashes and the user sees ugly stack traces (e.g., `googleapi: Error 503`).

To solve this, we use a **centralized UI fallback mechanism** that traps raw SDK errors and gracefully degrades the UI without disrupting the core ReAct loop.

## The Architecture

The error handling logic lives in `internal/mcpagent/provider.go` and consists of two functions:

### 1. `CategorizeLLMError(err error) LLMErrorCategory`
This function intercepts an error, inspects it for `langchaingo` normalizations, unwraps leaked provider-specific errors (like `*googleapi.Error`), and falls back to string matching.

It maps the messy error into one of four clean categories:
* `ErrCategoryAuth` (401, 403, 404, invalid keys, revoked models)
* `ErrCategoryOverload` (429, 503, rate limits, high demand)
* `ErrCategoryTimeout` (504, context deadline exceeded)
* `ErrCategoryUnknown` (Prompt parsing errors, network disconnects)

### 2. `GetLLMFallbackMessage(cat LLMErrorCategory) string`
This function takes a category and returns a beautifully formatted, non-emoji Markdown string to show the user.
* If it returns a non-empty string, **the turn is considered saved**. We show the user this message.
* If it returns `""` (for `ErrCategoryUnknown`), the error is fatal and should be bubbled up.

## How to use it

We deliberately **do not** wrap the original `a.provider.Complete(...)` function. We keep the core LLM signatures untouched. Instead, you just "pick up" the error immediately after the LLM call fails.

### Example (from `agent.go`)

```go
response, usage, err := a.provider.Complete(ctx, history, tools, completeOpts...)
if err != nil {
// 1. Pick up the error and categorize it
errCategory := CategorizeLLMError(err)

// 2. Ask for a clean UI message
msg := GetLLMFallbackMessage(errCategory)

// 3. If there is no clean fallback, bubble up the fatal error
if msg == "" {
return "", history, nil, nil, fmt.Errorf("llm completion failed: %w", err)
}

// 4. Otherwise, inject the fallback message as an AI response and return a nil error!
// This gracefully completes the turn and renders the message in the UI.
history = append(history, HistoryEntry{
"role": "assistant",
"content": msg,
"text": msg,
})
return msg, history, nil, nil, nil
}
```

## Adding new Fallback Scenarios

If you need to handle a new failure mode (e.g. `ContextTooLarge`):
1. Add `ErrCategoryContextSize` to the const block in `provider.go`.
2. Add the detection logic to `CategorizeLLMError`.
3. Add the exact Markdown message you want the user to see to the `switch` block in `GetLLMFallbackMessage`.

This guarantees every MCP LLM call across the app degrades consistently.
94 changes: 94 additions & 0 deletions internal/aiconnectors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package aiconnectors

import (
"errors"
"strings"

"github.com/tmc/langchaingo/llms"
)

type LLMErrorCategory string

const (
ErrCategoryAuth LLMErrorCategory = "auth_failed"
ErrCategoryOverload LLMErrorCategory = "overloaded"
ErrCategoryTimeout LLMErrorCategory = "timeout"
ErrCategoryDeprecated LLMErrorCategory = "model_deprecated"
ErrCategoryUnknown LLMErrorCategory = "unknown"
)

// CategorizeLLMError maps raw SDK errors from Langchain or underlying clients
// into our standard failure categories for consistent UI fallback rendering.
func CategorizeLLMError(err error) LLMErrorCategory {
if err == nil {
return ErrCategoryUnknown
}



// 1. Langchain-Go normalized errors
var llmsErr *llms.Error
if errors.As(err, &llmsErr) {
if llmsErr.Code == llms.ErrCodeAuthentication || llmsErr.Code == llms.ErrCodeResourceNotFound {
return ErrCategoryAuth
}
if llmsErr.Code == llms.ErrCodeRateLimit {
return ErrCategoryOverload
}
}

// 2. Generic HTTP status codes
var scErr interface{ StatusCode() int }
if errors.As(err, &scErr) {
code := scErr.StatusCode()
if code == 401 || code == 403 || code == 404 {
return ErrCategoryAuth
}
if code == 429 || code == 500 || code == 502 || code == 503 || code == 504 {
return ErrCategoryOverload
}
}

var hscErr interface{ HTTPStatusCode() int }
if errors.As(err, &hscErr) {
code := hscErr.HTTPStatusCode()
if code == 401 || code == 403 || code == 404 {
return ErrCategoryAuth
}
if code == 429 || code == 500 || code == 502 || code == 503 || code == 504 {
return ErrCategoryOverload
}
}

// 3. Fallback string matching
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "deprecated") || strings.Contains(msg, "model not found") ||
strings.Contains(msg, "model_not_found") || strings.Contains(msg, "no such model") ||
strings.Contains(msg, "no longer available") {
return ErrCategoryDeprecated
}

if strings.Contains(msg, "context deadline") || strings.Contains(msg, "timeout") {
return ErrCategoryTimeout
}
if strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests") ||
strings.Contains(msg, "status code: 429") || strings.Contains(msg, "status code: 500") ||
strings.Contains(msg, "status code: 502") || strings.Contains(msg, "status code: 503") ||
strings.Contains(msg, "status code: 504") || strings.Contains(msg, "error 429") ||
strings.Contains(msg, "error 500") || strings.Contains(msg, "error 502") ||
strings.Contains(msg, "error 503") || strings.Contains(msg, "error 504") ||
strings.Contains(msg, "service unavailable") || strings.Contains(msg, "bad gateway") ||
strings.Contains(msg, "high demand") {
return ErrCategoryOverload
}
if strings.Contains(msg, "status code: 401") || strings.Contains(msg, "status code: 403") ||
strings.Contains(msg, "status code: 404") || strings.Contains(msg, "unauthorized") ||
strings.Contains(msg, "error 401") || strings.Contains(msg, "error 403") ||
strings.Contains(msg, "error 404") || strings.Contains(msg, "forbidden") {
return ErrCategoryAuth
}

return ErrCategoryUnknown
}


101 changes: 101 additions & 0 deletions internal/aiconnectors/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package aiconnectors

import (
"errors"
"net/http"
"testing"

"github.com/tmc/langchaingo/llms"
)

// mockHTTPError mocks an error that provides a StatusCode() method.
type mockHTTPError struct {
code int
msg string
}

func (e *mockHTTPError) Error() string {
return e.msg
}

func (e *mockHTTPError) StatusCode() int {
return e.code
}

func TestCategorizeLLMError(t *testing.T) {
tests := []struct {
name string
err error
want LLMErrorCategory
}{
{
name: "Nil error",
err: nil,
want: ErrCategoryUnknown,
},
{
name: "Langchain Auth Error",
err: &llms.Error{Code: llms.ErrCodeAuthentication},
want: ErrCategoryAuth,
},
{
name: "Langchain Rate Limit Error",
err: &llms.Error{Code: llms.ErrCodeRateLimit},
want: ErrCategoryOverload,
},
{
name: "HTTP 401 via StatusCode()",
err: &mockHTTPError{code: http.StatusUnauthorized, msg: "unauthorized"},
want: ErrCategoryAuth,
},
{
name: "HTTP 503 via StatusCode()",
err: &mockHTTPError{code: http.StatusServiceUnavailable, msg: "service unavailable"},
want: ErrCategoryOverload,
},
{
name: "String Match: context deadline",
err: errors.New("operation failed: context deadline exceeded"),
want: ErrCategoryTimeout,
},
{
name: "String Match: high demand",
err: errors.New("googleapi: Error 503: This model is currently experiencing high demand."),
want: ErrCategoryOverload,
},
{
name: "String Match: error 429",
err: errors.New("provider returned error 429 too many requests"),
want: ErrCategoryOverload,
},
{
name: "String Match: model not found",
err: errors.New("model not found or deprecated"),
want: ErrCategoryAuth,
},
{
name: "String Match: error 404",
err: errors.New("error 404: resource not found"),
want: ErrCategoryAuth,
},
{
name: "String Match: error 502",
err: errors.New("googleapi: error 502: bad gateway"),
want: ErrCategoryOverload,
},
{
name: "Unknown Error",
err: errors.New("something went completely wrong"),
want: ErrCategoryUnknown,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CategorizeLLMError(tt.err)
if got != tt.want {
t.Errorf("CategorizeLLMError() = %v, want %v", got, tt.want)
}
})
}
}
44 changes: 28 additions & 16 deletions internal/api/chat_conversations_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ type ChatConversationSummary struct {
// ChatMessageOut is one persisted message, with any charts and file exports
// it produced.
type ChatMessageOut struct {
ID int64 `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Charts []WebChatChart `json:"charts,omitempty"`
Files []WebChatFile `json:"files,omitempty"`
ID int64 `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Charts []WebChatChart `json:"charts,omitempty"`
Files []WebChatFile `json:"files,omitempty"`
SuggestedQuestions []mcpagent.SuggestedQuestionCategory `json:"suggested_questions,omitempty"`
DebugArtifacts json.RawMessage `json:"debug_artifacts,omitempty"`
DebugArtifacts json.RawMessage `json:"debug_artifacts,omitempty"`
ActionCard *mcpagent.ActionCard `json:"action_card,omitempty"`
}

// ChatConversationDetail is a full conversation with its message history, for
Expand Down Expand Up @@ -201,19 +202,29 @@ func (s *Server) GetConversation(c echo.Context) error {
})
}
var suggestedQuestions []mcpagent.SuggestedQuestionCategory
var actionCard *mcpagent.ActionCard
for _, entry := range m.RawHistoryEntries {
if sq, ok := entry["suggested_questions"].([]mcpagent.SuggestedQuestionCategory); ok && len(sq) > 0 {
suggestedQuestions = sq
break
} else if rawSq, ok := entry["suggested_questions"]; ok && rawSq != nil {
if b, err := json.Marshal(rawSq); err == nil {
if err := json.Unmarshal(b, &suggestedQuestions); err != nil {
log.Warn().Err(err).Msg("failed to unmarshal raw suggested_questions")
} else if len(suggestedQuestions) > 0 {
break
}
// After JSONB round-trip from Postgres, all values come back as
// generic types ([]interface{}, map[string]interface{}, etc.),
// not as typed Go structs. Re-marshal each entry and read the
// fields we care about from a plain map so type assertions are safe.
entryBytes, err := json.Marshal(entry)
if err != nil {
continue
}
var plain map[string]json.RawMessage
if err := json.Unmarshal(entryBytes, &plain); err != nil {
continue
}
if ac, ok := plain["action_card"]; ok && actionCard == nil {
var card mcpagent.ActionCard
if err := json.Unmarshal(ac, &card); err == nil {
actionCard = &card
}
}
if sq, ok := plain["suggested_questions"]; ok && len(suggestedQuestions) == 0 {
_ = json.Unmarshal(sq, &suggestedQuestions)
}
}
out.Messages = append(out.Messages, ChatMessageOut{
ID: m.ID,
Expand All @@ -223,6 +234,7 @@ func (s *Server) GetConversation(c echo.Context) error {
Files: files,
SuggestedQuestions: suggestedQuestions,
DebugArtifacts: m.DebugArtifacts,
ActionCard: actionCard,
})
}
return c.JSON(http.StatusOK, out)
Expand Down
Loading
Loading