Detailed changes
@@ -3,6 +3,7 @@ package anthropic
import (
"cmp"
"errors"
+ "io"
"net/http"
"regexp"
"strconv"
@@ -32,6 +33,14 @@ func toProviderErr(err error) error {
return providerErr
}
+ // Wrap in a `ProviderError` so `.IsRetriable()` works.
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ return &fantasy.ProviderError{
+ Title: "stream transport error",
+ Message: err.Error(),
+ Cause: err,
+ }
+ }
return err
}
@@ -0,0 +1,67 @@
+package anthropic
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "testing"
+
+ "charm.land/fantasy"
+)
+
+func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ err error
+ }{
+ {"direct", io.ErrUnexpectedEOF},
+ {"wrapped", fmt.Errorf("read stream: %w", io.ErrUnexpectedEOF)},
+ {"double_wrapped", fmt.Errorf("anthropic: %w", fmt.Errorf("sse: %w", io.ErrUnexpectedEOF))},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := toProviderErr(tc.err)
+
+ var providerErr *fantasy.ProviderError
+ if !errors.As(got, &providerErr) {
+ t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError (got %T)", tc.err, got)
+ }
+ if !errors.Is(providerErr.Cause, io.ErrUnexpectedEOF) {
+ t.Errorf("ProviderError.Cause = %v, want chain containing io.ErrUnexpectedEOF", providerErr.Cause)
+ }
+ if !providerErr.IsRetryable() {
+ t.Error("wrapped io.ErrUnexpectedEOF must be retryable so retry.go engages")
+ }
+ })
+ }
+}
+
+func TestToProviderErr_PassesThroughUnrelatedErrors(t *testing.T) {
+ t.Parallel()
+
+ err := errors.New("something unrelated")
+ got := toProviderErr(err)
+ if got != err {
+ t.Errorf("toProviderErr mutated unrelated error: got %v, want %v", got, err)
+ }
+}
+
+func TestToProviderErr_PassesThroughPlainEOF(t *testing.T) {
+ t.Parallel()
+
+ // A clean io.EOF at the end of a stream is not a failure — the streaming
+ // handler in anthropic.go treats it as a normal terminator and never
+ // calls toProviderErr with io.EOF. But if it ever did, we should not
+ // wrap it: io.EOF is not "retryable" in the ProviderError sense.
+ got := toProviderErr(io.EOF)
+ var providerErr *fantasy.ProviderError
+ if errors.As(got, &providerErr) {
+ t.Errorf("toProviderErr wrapped io.EOF as ProviderError; should pass through")
+ }
+}
@@ -3,6 +3,7 @@ package google
import (
"cmp"
"errors"
+ "io"
"regexp"
"strconv"
@@ -15,6 +16,14 @@ var googleContextPattern = regexp.MustCompile(`input token count.*?(\d+).*?excee
func toProviderErr(err error) error {
var apiErr genai.APIError
if !errors.As(err, &apiErr) {
+ // Wrap in a `ProviderError` so `.IsRetriable()` works.
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ return &fantasy.ProviderError{
+ Title: "stream transport error",
+ Message: err.Error(),
+ Cause: err,
+ }
+ }
return err
}
@@ -0,0 +1,63 @@
+package google
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "testing"
+
+ "charm.land/fantasy"
+)
+
+func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ err error
+ }{
+ {"direct", io.ErrUnexpectedEOF},
+ {"wrapped", fmt.Errorf("read stream: %w", io.ErrUnexpectedEOF)},
+ {"double_wrapped", fmt.Errorf("google: %w", fmt.Errorf("sse: %w", io.ErrUnexpectedEOF))},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := toProviderErr(tc.err)
+
+ var providerErr *fantasy.ProviderError
+ if !errors.As(got, &providerErr) {
+ t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError (got %T)", tc.err, got)
+ }
+ if !errors.Is(providerErr.Cause, io.ErrUnexpectedEOF) {
+ t.Errorf("ProviderError.Cause = %v, want chain containing io.ErrUnexpectedEOF", providerErr.Cause)
+ }
+ if !providerErr.IsRetryable() {
+ t.Error("wrapped io.ErrUnexpectedEOF must be retryable so retry.go engages")
+ }
+ })
+ }
+}
+
+func TestToProviderErr_PassesThroughUnrelatedErrors(t *testing.T) {
+ t.Parallel()
+
+ err := errors.New("something unrelated")
+ got := toProviderErr(err)
+ if got != err {
+ t.Errorf("toProviderErr mutated unrelated error: got %v, want %v", got, err)
+ }
+}
+
+func TestToProviderErr_PassesThroughPlainEOF(t *testing.T) {
+ t.Parallel()
+
+ got := toProviderErr(io.EOF)
+ var providerErr *fantasy.ProviderError
+ if errors.As(got, &providerErr) {
+ t.Errorf("toProviderErr wrapped io.EOF as ProviderError; should pass through")
+ }
+}
@@ -34,6 +34,14 @@ func toProviderErr(err error) error {
return providerErr
}
+ // Wrap in a `ProviderError` so `.IsRetriable()` works.
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ return &fantasy.ProviderError{
+ Title: "stream transport error",
+ Message: err.Error(),
+ Cause: err,
+ }
+ }
return err
}
@@ -0,0 +1,63 @@
+package openai
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "testing"
+
+ "charm.land/fantasy"
+)
+
+func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ err error
+ }{
+ {"direct", io.ErrUnexpectedEOF},
+ {"wrapped", fmt.Errorf("read stream: %w", io.ErrUnexpectedEOF)},
+ {"double_wrapped", fmt.Errorf("openai: %w", fmt.Errorf("sse: %w", io.ErrUnexpectedEOF))},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := toProviderErr(tc.err)
+
+ var providerErr *fantasy.ProviderError
+ if !errors.As(got, &providerErr) {
+ t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError (got %T)", tc.err, got)
+ }
+ if !errors.Is(providerErr.Cause, io.ErrUnexpectedEOF) {
+ t.Errorf("ProviderError.Cause = %v, want chain containing io.ErrUnexpectedEOF", providerErr.Cause)
+ }
+ if !providerErr.IsRetryable() {
+ t.Error("wrapped io.ErrUnexpectedEOF must be retryable so retry.go engages")
+ }
+ })
+ }
+}
+
+func TestToProviderErr_PassesThroughUnrelatedErrors(t *testing.T) {
+ t.Parallel()
+
+ err := errors.New("something unrelated")
+ got := toProviderErr(err)
+ if got != err {
+ t.Errorf("toProviderErr mutated unrelated error: got %v, want %v", got, err)
+ }
+}
+
+func TestToProviderErr_PassesThroughPlainEOF(t *testing.T) {
+ t.Parallel()
+
+ got := toProviderErr(io.EOF)
+ var providerErr *fantasy.ProviderError
+ if errors.As(got, &providerErr) {
+ t.Errorf("toProviderErr wrapped io.EOF as ProviderError; should pass through")
+ }
+}