From 11d5d948c75fd79f6804aece6162259abec21bb1 Mon Sep 17 00:00:00 2001 From: Lukasz Gonczarik <17554958+lgonczarik@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:39:20 +0200 Subject: [PATCH] Fix paged fetch counting the first page twice PagedResourceHelper.fetchItems started at page 0, but Hetzner Cloud API pages are 1-based and page=0 is served as page 1. Whenever a result spans more than one page, the first page was returned twice, so getAllServers and getAllPrimaryIps reported 25 extra items. Verified against the live API: GET /v1/servers?page=0 returns meta.pagination.page=1 and the same items as page=1. In the Jenkins hetzner-cloud plugin this makes a cloud with 26 servers count as 51, so it hits its instance cap with about half the agents actually running. Start the loop at page 1 and add a test that emulates the API's page=0 behaviour and asserts pages 1..n are requested without duplicates. --- .../hetznerclient/PagedResourceHelper.java | 5 ++- .../dnation/hetznerclient/BasicTest.java | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/main/java/cloud/dnation/hetznerclient/PagedResourceHelper.java b/src/main/java/cloud/dnation/hetznerclient/PagedResourceHelper.java index d9ecb8d..5731d53 100644 --- a/src/main/java/cloud/dnation/hetznerclient/PagedResourceHelper.java +++ b/src/main/java/cloud/dnation/hetznerclient/PagedResourceHelper.java @@ -33,7 +33,7 @@ public class PagedResourceHelper { * Consume all items from paginated REST endpoint. * * @param labelSelector label selector to restrict items only to those that match selector - * @param pageSupplier {@link BiFunction} that takes page index (zero based) and selector and produces {@link Response} + * @param pageSupplier {@link BiFunction} that takes page index (one based) and selector and produces {@link Response} * @param itemsGetter {@link Function} that takes response from pageSupplier and extracts list of items * @return all items combined to single list * @param item type @@ -43,7 +43,8 @@ public static String labelSelector, BiFunction> pageSupplier, Function> itemsGetter) throws IOException { final List result = new ArrayList<>(); - for (int i = 0; ; i++) { + // Hetzner API pages are 1-based. Page 0 is served as page 1, so starting at 0 returns the first page twice. + for (int i = 1; ; i++) { final Response page = pageSupplier.apply(i, labelSelector).execute(); assertValidResponse(page); final X body = page.body(); diff --git a/src/test/java/cloud/dnation/hetznerclient/BasicTest.java b/src/test/java/cloud/dnation/hetznerclient/BasicTest.java index b11bc3e..c510908 100644 --- a/src/test/java/cloud/dnation/hetznerclient/BasicTest.java +++ b/src/test/java/cloud/dnation/hetznerclient/BasicTest.java @@ -15,8 +15,11 @@ */ package cloud.dnation.hetznerclient; +import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.QueueDispatcher; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -25,7 +28,10 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.stream.Collectors; import static cloud.dnation.hetznerclient.TestHelper.resourceAsString; import static org.junit.Assert.assertEquals; @@ -186,4 +192,39 @@ public void testPaginationHelper() throws IOException { assertEquals("1.2.3.29", items.get(25).getIp()); assertEquals("1.2.3.30", items.get(26).getIp()); } + + @Test + public void testPaginationHelperRequestsOneBasedPages() throws IOException { + final List requestedPages = new ArrayList<>(); + ws.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String page = request.getRequestUrl().queryParameter("page"); + requestedPages.add(page); + try { + switch (page) { + // Hetzner API pages are 1-based and treat page=0 as page=1 + case "0": + case "1": + return new MockResponse().setBody(resourceAsString("paging-primary-ips-1.json")); + case "2": + return new MockResponse().setBody(resourceAsString("paging-primary-ips-2.json")); + default: + return new MockResponse().setResponseCode(404); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + try { + List items = PagedResourceHelper.getAllPrimaryIps(api, ""); + assertEquals(Arrays.asList("1", "2"), requestedPages); + List ips = items.stream().map(PrimaryIpDetail::getIp).collect(Collectors.toList()); + assertEquals(27, ips.size()); + assertEquals(ips.size(), new HashSet<>(ips).size()); + } finally { + ws.setDispatcher(new QueueDispatcher()); + } + } }