openai_upstream_transport_error_test.go 3.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "net"
  7. "os"
  8. "syscall"
  9. "testing"
  10. )
  11. // TestClassifyUpstreamTransportError pins which transport-level upstream failures
  12. // are "persistent" (retrying the same proxy/account is pointless — evict + alert)
  13. // versus "transient" (a blip — fail over to a healthy account but do not evict).
  14. //
  15. // The motivating incident: a SOCKS5 proxy whose credentials expired returned
  16. // `username/password authentication failed`, yet the account kept being scheduled.
  17. func TestClassifyUpstreamTransportError(t *testing.T) {
  18. cases := []struct {
  19. name string
  20. err error
  21. persistent bool
  22. }{
  23. // Durable — config/credential/routing problems. Retrying same proxy won't help.
  24. {"socks5 proxy credential rejected", errors.New(`Post "https://chatgpt.com/backend-api/codex/responses": socks connect tcp 85.255.176.68:12324->chatgpt.com:443: username/password authentication failed`), true},
  25. {"proxy connection refused", errors.New(`proxyconnect tcp: dial tcp 1.2.3.4:1080: connect: connection refused`), true},
  26. {"no route to host", errors.New(`dial tcp 1.2.3.4:443: connect: no route to host`), true},
  27. {"dns resolution failure", errors.New(`dial tcp: lookup proxy.example.com: no such host`), true},
  28. {"network unreachable", errors.New(`dial tcp 1.2.3.4:443: connect: network is unreachable`), true},
  29. // Transient — a temporary blip. Fail over, but do NOT evict the account.
  30. {"client timeout", errors.New(`Post "https://chatgpt.com/...": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`), false},
  31. {"i/o timeout", errors.New(`dial tcp 1.2.3.4:443: i/o timeout`), false},
  32. {"connection reset by peer", errors.New(`read tcp 10.0.0.1:5->2.2.2.2:443: read: connection reset by peer`), false},
  33. {"unexpected eof", errors.New(`unexpected EOF`), false},
  34. {"broken pipe", errors.New(`write tcp 10.0.0.1:5->2.2.2.2:443: write: broken pipe`), false},
  35. {"nil error", nil, false},
  36. // ── Typed-error cases ──────────────────────────────────────────────
  37. // ECONNREFUSED wrapped in the canonical net.OpError shape Go produces.
  38. {
  39. "ECONNREFUSED via net.OpError",
  40. &net.OpError{
  41. Op: "dial",
  42. Net: "tcp",
  43. Err: &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED},
  44. },
  45. true,
  46. },
  47. // Bare syscall error (errors.Is traverses the chain).
  48. {"ECONNREFUSED bare", syscall.ECONNREFUSED, true},
  49. {"EHOSTUNREACH bare", syscall.EHOSTUNREACH, true},
  50. {"ENETUNREACH bare", syscall.ENETUNREACH, true},
  51. // *net.DNSError with IsNotFound — permanent DNS lookup failure.
  52. {
  53. "DNS not found (IsNotFound=true)",
  54. &net.DNSError{Err: "no such host", Name: "proxy.example.com", IsNotFound: true},
  55. true,
  56. },
  57. // *net.DNSError with IsNotFound=false — transient DNS timeout (not persistent).
  58. {
  59. "DNS timeout (IsNotFound=false)",
  60. &net.DNSError{Err: "i/o timeout", Name: "proxy.example.com", IsTimeout: true},
  61. false,
  62. },
  63. // context.Canceled — client gone; NOT classified as persistent.
  64. {"context.Canceled", context.Canceled, false},
  65. // context.DeadlineExceeded — slow upstream; NOT persistent.
  66. {"context.DeadlineExceeded", context.DeadlineExceeded, false},
  67. }
  68. for _, tc := range cases {
  69. t.Run(tc.name, func(t *testing.T) {
  70. got := classifyUpstreamTransportError(tc.err).Persistent
  71. if got != tc.persistent {
  72. t.Fatalf("classifyUpstreamTransportError(%q).Persistent = %v, want %v", errString(tc.err), got, tc.persistent)
  73. }
  74. })
  75. }
  76. }
  77. func errString(err error) string {
  78. if err == nil {
  79. return "<nil>"
  80. }
  81. return err.Error()
  82. }