16e4f8a1f2
Add retry_test.go (NEW) covering DefaultRetryPolicy, first-attempt success, idempotent-verb retry, idempotency-key retry, MaxAttempts exhaustion, zero-MaxAttempts defaulting, transient+non-idempotent+ no-key bail, ctx-cancel mid-backoff, exponential backoff growth + cap, and contains() substring helper. Extend idempotency_test.go with Sweep, empty-key Put/Get, and empty-key WithIdempotencyKey. Extend handshake_log_test.go with LogHandshakeFromCert happy path (real x509 cert → fingerprint) and FingerprintOfCert round-trip. Coverage: 84.6% → 93.0%. go test -race PASS. No production code changed; no new seams (httptest already covered DispatchClient). ---ci--- project: orca phase: 1 milestone: v0.8 status: execute ---/ci---
171 lines
4.4 KiB
Go
171 lines
4.4 KiB
Go
package transport
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestIdempotencyStorePutGet(t *testing.T) {
|
|
s := NewIdempotencyStore()
|
|
if _, ok := s.Get("missing"); ok {
|
|
t.Fatal("expected missing key to return ok=false")
|
|
}
|
|
s.Put("k1", "job-1")
|
|
if jobID, ok := s.Get("k1"); !ok || jobID != "job-1" {
|
|
t.Errorf("Get(k1): got (%q, %v), want (job-1, true)", jobID, ok)
|
|
}
|
|
}
|
|
|
|
func TestIdempotencyStoreExpiry(t *testing.T) {
|
|
s := NewIdempotencyStore()
|
|
// Manually insert an expired entry.
|
|
s.entries["expired"] = dedupeEntry{
|
|
key: "expired",
|
|
jobID: "old-job",
|
|
expiresAt: time.Now().Add(-1 * time.Minute),
|
|
}
|
|
if _, ok := s.Get("expired"); ok {
|
|
t.Fatal("expected expired entry to return ok=false")
|
|
}
|
|
if _, exists := s.entries["expired"]; exists {
|
|
t.Error("expected expired entry to be removed by Get")
|
|
}
|
|
}
|
|
|
|
func TestIdempotencyStoreContext(t *testing.T) {
|
|
ctx := WithIdempotencyKey(context.Background(), "key-1")
|
|
if got := IdempotencyKeyFromContext(ctx); got != "key-1" {
|
|
t.Errorf("IdempotencyKeyFromContext: got %q, want key-1", got)
|
|
}
|
|
ctx2 := context.Background()
|
|
if got := IdempotencyKeyFromContext(ctx2); got != "" {
|
|
t.Errorf("IdempotencyKeyFromContext(empty): got %q, want \"\"", got)
|
|
}
|
|
}
|
|
|
|
func TestRetrySucceedsAfterTransient(t *testing.T) {
|
|
calls := 0
|
|
got, err := Do(context.Background(), DefaultRetryPolicy(),
|
|
func(_ context.Context, attempt int) (string, bool, error) {
|
|
calls++
|
|
if attempt < 3 {
|
|
return "", true, errors.New("connection refused: try again")
|
|
}
|
|
return "ok", true, nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Do: %v", err)
|
|
}
|
|
if got != "ok" {
|
|
t.Errorf("Do: got %q, want ok", got)
|
|
}
|
|
if calls != 3 {
|
|
t.Errorf("Do: got %d calls, want 3", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryNoKeyOnTransient(t *testing.T) {
|
|
// Without an idempotency key AND a non-idempotent verb, a
|
|
// transient error on the first attempt must NOT retry (REQ-037).
|
|
calls := 0
|
|
_, err := Do(context.Background(), DefaultRetryPolicy(),
|
|
func(_ context.Context, _ int) (string, bool, error) {
|
|
calls++
|
|
return "", false, errors.New("connection refused")
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("expected 1 call (no retry without key), got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryPermanentError(t *testing.T) {
|
|
calls := 0
|
|
_, err := Do(context.Background(), DefaultRetryPolicy(),
|
|
func(_ context.Context, _ int) (string, bool, error) {
|
|
calls++
|
|
return "", true, ErrPermanent
|
|
})
|
|
if !errors.Is(err, ErrPermanent) {
|
|
t.Errorf("expected ErrPermanent, got %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("expected 1 call (permanent = no retry), got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryContextCancel(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
calls := 0
|
|
_, err := Do(ctx, DefaultRetryPolicy(),
|
|
func(_ context.Context, _ int) (string, bool, error) {
|
|
calls++
|
|
return "", true, errors.New("EOF")
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("expected context.Canceled, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIdempotencyStoreSweep(t *testing.T) {
|
|
s := NewIdempotencyStore()
|
|
s.Put("live-1", "job-1")
|
|
s.entries["expired"] = dedupeEntry{
|
|
key: "expired",
|
|
jobID: "old-job",
|
|
expiresAt: time.Now().Add(-1 * time.Minute),
|
|
}
|
|
s.Sweep()
|
|
if _, ok := s.entries["expired"]; ok {
|
|
t.Error("Sweep did not remove expired entry")
|
|
}
|
|
if _, ok := s.entries["live-1"]; !ok {
|
|
t.Error("Sweep removed live entry")
|
|
}
|
|
}
|
|
|
|
func TestIdempotencyStorePutEmpty(t *testing.T) {
|
|
s := NewIdempotencyStore()
|
|
s.Put("", "job-1")
|
|
s.Put("k1", "")
|
|
if _, ok := s.Get("k1"); ok {
|
|
t.Error("Put with empty jobID should not store")
|
|
}
|
|
if _, ok := s.Get(""); ok {
|
|
t.Error("Get with empty key should return false")
|
|
}
|
|
}
|
|
|
|
func TestWithIdempotencyKeyEmpty(t *testing.T) {
|
|
ctx := context.Background()
|
|
got := WithIdempotencyKey(ctx, "")
|
|
if got != ctx {
|
|
t.Error("WithIdempotencyKey with empty key should return ctx unchanged")
|
|
}
|
|
}
|
|
|
|
func TestIsTransient(t *testing.T) {
|
|
cases := []struct {
|
|
err error
|
|
want bool
|
|
}{
|
|
{nil, false},
|
|
{errors.New("connection refused"), true},
|
|
{errors.New("i/o timeout"), true},
|
|
{errors.New("EOF"), true},
|
|
{errors.New("no such host"), true},
|
|
{errors.New("connection reset by peer"), true},
|
|
{errors.New("invalid spec"), false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := IsTransient(c.err); got != c.want {
|
|
t.Errorf("IsTransient(%v): got %v, want %v", c.err, got, c.want)
|
|
}
|
|
}
|
|
}
|