Files
coder/coderd/x/chatd/chaterror/signals_test.go
T
Ethan 70f031d793 feat(coderd/chatd): structured chat error classification and retry hardening (#23275)
> **PR Stack**
> 1. #23351 ← `#23282`
> 2. #23282 ← `#23275`
> 3. **#23275** ← `#23349` *(you are here)*
> 4. #23349 ← `main`

---

## Summary

Extracts a structured error classification subsystem for agent chat
(`chatd`) so that retry and error payloads carry machine-readable
metadata — error kind, provider name, HTTP status code, and retryability
— instead of raw error strings.

This is the **backend half** of the error-handling work. The frontend
counterpart is in #23282.

## Changes

### New package: `coderd/chatd/chaterror/`

Canonical error classification — extracts error kind, provider, status
code, and user-facing message from raw provider errors. One source of
truth that drives both retry policy and stream payloads.

- **`kind.go`**: Error kind enum (`rate_limit`, `timeout`, `auth`,
`config`, `overloaded`, `unknown`).
- **`signals.go`**: Signal extraction — parses provider name, HTTP
status code, and retryability from error strings and wrapped types.
- **`classify.go`**: Classification logic — maps extracted signals to an
error kind.
- **`message.go`**: User-facing message templates keyed by kind +
signals.
- **`payload.go`**: Projectors that build `ChatStreamError` and
`ChatStreamRetry` payloads from a classified error.

### Modified

- **`codersdk/chats.go`**: Added `Kind`, `Provider`, `Retryable`,
`StatusCode` fields to `ChatStreamError` and `ChatStreamRetry`.
- **`coderd/chatd/chatretry/`**: Thinned to retry-policy only;
classification logic moved to `chaterror`.
- **`coderd/chatd/chatloop/`**: Added per-attempt first-chunk timeout
(60 s) via `guardedStream` wrapper — produces retryable
`startup_timeout` errors instead of hanging forever.
- **`coderd/chatd/chatd.go`**: Publishes normalized retry/error payloads
via `chaterror` projectors.
2026-03-25 13:47:54 +11:00

70 lines
2.5 KiB
Go

package chaterror_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
)
func TestExtractStatusCode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want int
}{
{name: "Status", input: "received status 429 from upstream", want: 429},
{name: "StatusCode", input: "status code: 503", want: 503},
{name: "HTTP", input: "http 502 bad gateway", want: 502},
{name: "Standalone", input: "got 504 from upstream", want: 504},
{name: "MultipleStandaloneCodesReturnFirstMatch", input: "retrying 503 after 429", want: 503},
{name: "MixedCaseViaCallerLowering", input: "HTTP 503 bad gateway", want: 503},
{name: "PortNumberIPIsNotStatus", input: "dial tcp 10.0.0.1:503: connection refused", want: 0},
{name: "PortNumberHostIsNotStatus", input: "proxy.internal:502 unreachable", want: 0},
{name: "PortNumberDialIsNotStatus", input: "dial tcp 172.16.0.5:429: refused", want: 0},
{name: "PortThenRealStatusReturnsRealStatus", input: "proxy at 10.0.0.1:500 returned 503", want: 503},
{name: "NoFabricatedOverloadStatus", input: "anthropic overloaded_error", want: 0},
{name: "NoFabricatedRateLimitStatus", input: "too many requests", want: 0},
{name: "NoFabricatedBadGatewayStatus", input: "bad gateway", want: 0},
{name: "NoFabricatedServiceUnavailableStatus", input: "service unavailable", want: 0},
{name: "NoStatus", input: "boom", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chaterror.ExtractStatusCodeForTest(strings.ToLower(tt.input)))
})
}
}
func TestDetectProvider(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{name: "OpenAICompatBeatsOpenAI", input: "openai-compat upstream error", want: "openai-compat"},
{name: "OpenAICompatibleAlias", input: "openai compatible proxy", want: "openai-compat"},
{name: "AzureOpenAI", input: "azure openai rate limited", want: "azure"},
{name: "OpenAI", input: "openai rate limited", want: "openai"},
{name: "Anthropic", input: "anthropic overloaded", want: "anthropic"},
{name: "GoogleGemini", input: "gemini timeout", want: "google"},
{name: "Vercel", input: "vercel ai gateway 503", want: "vercel"},
{name: "Unknown", input: "local provider error", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chaterror.DetectProviderForTest(strings.ToLower(tt.input)))
})
}
}