Files
xinmi/internal/application/service/wiki_ingest_retry_test.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

100 lines
4.0 KiB
Go

package service
import (
"context"
"errors"
"testing"
)
// TestIsTransientLLMError_HTTPStatuses covers the status codes we know
// the generic REST chat provider bubbles up as "API request failed with
// status NNN: ...". The status-code arm of the classifier is the one
// that actually fires for the 504 gateway timeouts the upstream LLM
// vendors throw most often.
func TestIsTransientLLMError_HTTPStatuses(t *testing.T) {
ctx := context.Background()
cases := []struct {
msg string
want bool
}{
{"API request failed with status 504: Remote error, timeout with 60", true},
{"API request failed with status 503: service unavailable", true},
{"API request failed with status 502: bad gateway", true},
{"API request failed with status 500: internal server error", true},
{"API request failed with status 429: rate limited", true},
{"API request failed with status 408: request timeout", true},
{"API request failed with status 520: Web server returned unknown", true},
{"API request failed with status 524: timeout (Cloudflare)", true},
// 4xx permanent: a retry will just fail the same way.
{"API request failed with status 400: bad request", false},
{"API request failed with status 401: unauthorized", false},
{"API request failed with status 403: forbidden (quota exhausted)", false},
{"API request failed with status 404: model not found", false},
}
for _, tc := range cases {
got := isTransientLLMError(ctx, errors.New(tc.msg))
if got != tc.want {
t.Errorf("isTransientLLMError(%q) = %v, want %v", tc.msg, got, tc.want)
}
}
}
// TestIsTransientLLMError_TransportSubstrings covers providers (and Go
// stdlib errors) that surface transient conditions as free-form text
// without a numeric status code — DNS blips, TLS handshake failures,
// broken pipes during an in-flight body read, etc.
func TestIsTransientLLMError_TransportSubstrings(t *testing.T) {
ctx := context.Background()
cases := []struct {
msg string
want bool
}{
{"send request: dial tcp: lookup api.example.com: no such host", true},
{"send request: read tcp 10.0.0.1: i/o timeout", true},
{"send request: Post \"...\": context deadline exceeded", true},
{"send request: connection reset by peer", true},
{"send request: connection refused", true},
{"send request: broken pipe", true},
{"send request: tls handshake timeout", true},
{"send request: unexpected EOF", true},
// Not transient: the error is actionable (missing model id,
// malformed tool call, etc.) — retry is just wasted budget.
{"model not configured for tool use", false},
{"invalid JSON in tool arguments", false},
{"parse template: syntax error", false},
}
for _, tc := range cases {
got := isTransientLLMError(ctx, errors.New(tc.msg))
if got != tc.want {
t.Errorf("isTransientLLMError(%q) = %v, want %v", tc.msg, got, tc.want)
}
}
}
// TestIsTransientLLMError_AbortsWhenParentCtxDone ensures a cancelled
// outer context short-circuits the retry decision. Without this guard
// an ingest task that was interrupted (asynq shutdown, deletion race,
// parent timeout) would keep retrying the LLM during its teardown
// window and waste the remainder of the backoff budget.
func TestIsTransientLLMError_AbortsWhenParentCtxDone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// This exact error would normally be classified transient (see
// TestIsTransientLLMError_HTTPStatuses), but a cancelled ctx must
// override that so the task can unwind promptly.
err := errors.New("API request failed with status 504: Remote error")
if isTransientLLMError(ctx, err) {
t.Fatal("cancelled ctx should short-circuit to non-transient")
}
}
// TestIsTransientLLMError_NilError is a sanity check — nil should never
// be treated as retryable, otherwise callers that forget to handle the
// happy path would spin forever.
func TestIsTransientLLMError_NilError(t *testing.T) {
if isTransientLLMError(context.Background(), nil) {
t.Fatal("nil error should not be transient")
}
}