package openstack import ( "context" "errors" "fmt" "io" "net" "net/http" "net/url" "testing" "time" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" ) func TestMockPaginationReturnsAllPages(t *testing.T) { m := NewMockClient() m.PageSize = 200 m.SeedMany("fip", 2500) seen := map[string]bool{} var sizes []int pages, err := m.ListFreeFloatingIPs(context.Background(), 200, func(page []FloatingIP) error { sizes = append(sizes, len(page)) for _, f := range page { if seen[f.ID] { t.Fatalf("duplicate id %s", f.ID) } seen[f.ID] = true } return nil }) if err != nil { t.Fatalf("list: %v", err) } if len(seen) != 2500 { t.Fatalf("expected 2500 unique fips, got %d", len(seen)) } if pages != 13 || len(sizes) != 13 || sizes[12] != 100 { t.Fatalf("expected 13 pages with a short last one, got pages=%d sizes=%v", pages, sizes) } if m.PageCalls != 13 { t.Fatalf("expected 13 page requests, got %d", m.PageCalls) } // ListFloatingIPs stays available on top of the paged method. all, err := m.ListFloatingIPs(context.Background()) if err != nil || len(all) != 2500 { t.Fatalf("ListFloatingIPs: n=%d err=%v", len(all), err) } } func TestMockPaginationExactMultipleOfPageSize(t *testing.T) { m := NewMockClient() m.SeedMany("fip", 400) pages, err := m.ListFreeFloatingIPs(context.Background(), 200, func([]FloatingIP) error { return nil }) if err != nil || pages != 2 { t.Fatalf("expected 2 non-empty pages, got pages=%d err=%v", pages, err) } } func TestPageRetryOnTransientErrors(t *testing.T) { m := NewMockClient() m.PageSize = 100 m.SeedMany("fip", 250) m.PageRetries = 5 var slept []time.Duration m.Sleep = func(ctx context.Context, d time.Duration) error { slept = append(slept, d) return nil } // Fail the first request twice, then the 2nd page once. m.ListFailures = []error{io.ErrUnexpectedEOF, errors.New("RemoteDisconnected('Remote end closed connection')"), nil, io.EOF} n := 0 pages, err := m.ListFreeFloatingIPs(context.Background(), 100, func(p []FloatingIP) error { n += len(p) return nil }) if err != nil { t.Fatalf("expected retries to succeed, got %v", err) } if n != 250 || pages != 3 { t.Fatalf("expected 250 fips in 3 pages, got %d in %d", n, pages) } want := []time.Duration{time.Second, 2 * time.Second, time.Second} if len(slept) != len(want) { t.Fatalf("expected %d backoffs, got %v", len(want), slept) } for i := range want { if slept[i] != want[i] { t.Fatalf("backoff[%d]=%v want %v (all %v)", i, slept[i], want[i], slept) } } } func TestPageRetryGivesUpAfterRetries(t *testing.T) { m := NewMockClient() m.SeedMany("fip", 10) m.PageRetries = 2 m.Sleep = func(context.Context, time.Duration) error { return nil } m.ListFailure = io.ErrUnexpectedEOF // sticky _, err := m.ListFreeFloatingIPs(context.Background(), 5, func([]FloatingIP) error { return nil }) if !errors.Is(err, io.ErrUnexpectedEOF) { t.Fatalf("expected the page error after retries, got %v", err) } if m.PageCalls != 3 { t.Fatalf("expected 1+2 attempts, got %d", m.PageCalls) } } func TestPageNonRetryableAborts(t *testing.T) { m := NewMockClient() m.SeedMany("fip", 10) m.PageRetries = 5 m.Sleep = func(context.Context, time.Duration) error { t.Fatal("must not back off for a non-retryable error") return nil } boom := errors.New("403 forbidden") m.ListFailures = []error{boom} called := false _, err := m.ListFreeFloatingIPs(context.Background(), 5, func([]FloatingIP) error { called = true; return nil }) if !errors.Is(err, boom) || called || m.PageCalls != 1 { t.Fatalf("expected immediate abort: err=%v called=%v calls=%d", err, called, m.PageCalls) } } func TestPageOnPageErrorStops(t *testing.T) { m := NewMockClient() m.SeedMany("fip", 50) stop := errors.New("stop") pages, err := m.ListFreeFloatingIPs(context.Background(), 10, func([]FloatingIP) error { return stop }) if !errors.Is(err, stop) || pages != 1 || m.PageCalls != 1 { t.Fatalf("expected stop after first page: pages=%d err=%v calls=%d", pages, err, m.PageCalls) } } func TestPageContextCancel(t *testing.T) { m := NewMockClient() m.SeedMany("fip", 100) m.PageDelay = 5 * time.Second ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() start := time.Now() _, err := m.ListFreeFloatingIPs(ctx, 10, func([]FloatingIP) error { return nil }) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("expected deadline error, got %v", err) } if time.Since(start) > 2*time.Second { t.Fatalf("cancel was not honoured promptly") } // Cancellation during the backoff sleep is honoured too. m2 := NewMockClient() m2.SeedMany("fip", 10) m2.PageRetries = 3 m2.ListFailure = io.EOF ctx2, cancel2 := context.WithCancel(context.Background()) m2.Sleep = func(ctx context.Context, d time.Duration) error { cancel2(); return ctx.Err() } _, err = m2.ListFreeFloatingIPs(ctx2, 5, func([]FloatingIP) error { return nil }) if !errors.Is(err, context.Canceled) { t.Fatalf("expected canceled, got %v", err) } } func TestIsRetryableListError(t *testing.T) { status := func(code int) error { return gophercloud.ErrUnexpectedResponseCode{Actual: code, Expected: []int{200}} } cases := []struct { name string err error want bool }{ {"nil", nil, false}, {"eof", io.EOF, true}, {"unexpected eof", io.ErrUnexpectedEOF, true}, {"wrapped eof", fmt.Errorf("list: %w", io.EOF), true}, {"remote disconnected text", errors.New("Get x: RemoteDisconnected('Remote end closed connection without response')"), true}, {"connection reset", errors.New("read tcp: connection reset by peer"), true}, {"connection refused", errors.New("dial tcp: connection refused"), true}, {"net timeout", &url.Error{Op: "Get", URL: "http://x", Err: &net.DNSError{IsTimeout: true}}, true}, {"500", status(http.StatusInternalServerError), true}, {"503 wrapped", fmt.Errorf("openstack: list floating ips: %w", status(503)), true}, {"429", status(http.StatusTooManyRequests), true}, {"400", status(http.StatusBadRequest), false}, {"401", status(http.StatusUnauthorized), false}, {"404", status(http.StatusNotFound), false}, {"canceled", context.Canceled, false}, {"plain", errors.New("boom"), false}, } for _, c := range cases { if got := IsRetryableListError(c.err); got != c.want { t.Errorf("%s: IsRetryableListError=%v want %v", c.name, got, c.want) } } } func TestPagedListOptsQuery(t *testing.T) { o := pagedListOpts{ListOpts: floatingipsListOpts("m1", 200), fields: fipListFields} q, err := o.ToFloatingIPListQuery() if err != nil { t.Fatal(err) } v, err := url.ParseQuery(q[1:]) if err != nil { t.Fatalf("parse %q: %v", q, err) } if v.Get("limit") != "200" || v.Get("marker") != "m1" { t.Fatalf("limit/marker missing in %q", q) } if got := v["fields"]; len(got) != 4 || got[0] != "id" || got[1] != "floating_ip_address" || got[2] != "port_id" || got[3] != "project_id" { t.Fatalf("unexpected fields %v in %q", got, q) } // Without any other option the query must still start with '?'. q, _ = pagedListOpts{fields: []string{"id"}}.ToFloatingIPListQuery() if q != "?fields=id" { t.Fatalf("got %q", q) } } func floatingipsListOpts(marker string, limit int) floatingips.ListOpts { return floatingips.ListOpts{Marker: marker, Limit: limit} }