From 1b92244e10f527f86dd50358caa1c592c15aa511 Mon Sep 17 00:00:00 2001 From: The-Jonsey Date: Thu, 13 Nov 2025 20:09:45 +0000 Subject: [PATCH 1/3] Add Retries And Backoff To GitLabSCMSource:getMembers --- .../gitlabbranchsource/GitLabSCMSource.java | 33 +++- .../gitlabbranchsource/helpers/Sleeper.java | 8 + .../GitLabSCMSourceTest.java | 141 +++++++++++++++--- 3 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 src/main/java/io/jenkins/plugins/gitlabbranchsource/helpers/Sleeper.java diff --git a/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java b/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java index 3e65766d..9bf8a10b 100644 --- a/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java +++ b/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java @@ -31,6 +31,7 @@ import hudson.util.ListBoxModel; import io.jenkins.plugins.gitlabbranchsource.helpers.GitLabAvatar; import io.jenkins.plugins.gitlabbranchsource.helpers.GitLabLink; +import io.jenkins.plugins.gitlabbranchsource.helpers.Sleeper; import io.jenkins.plugins.gitlabserverconfig.credentials.PersonalAccessToken; import io.jenkins.plugins.gitlabserverconfig.servers.GitLabServer; import io.jenkins.plugins.gitlabserverconfig.servers.GitLabServers; @@ -118,6 +119,10 @@ public class GitLabSCMSource extends AbstractGitSCMSource { private transient Project gitlabProject; private Long projectId; + private static final Integer MAX_RETRIES = 5; + + private static final Integer INITIAL_DELAY_MS = 5000; + /** * The cache of {@link ObjectMetadataAction} instances for each open MR. */ @@ -226,17 +231,41 @@ protected Project getGitlabProject(GitLabApi gitLabApi) { public HashMap getMembers() { HashMap members = new HashMap<>(); try { - GitLabApi gitLabApi = apiBuilder(this.getOwner(), serverName); - for (Member m : gitLabApi.getProjectApi().getAllMembers(projectPath)) { + for (Member m : getMembersWithRetries()) { members.put(m.getUsername(), m.getAccessLevel()); } } catch (GitLabApiException e) { LOGGER.log(Level.WARNING, "Exception while fetching members" + e, e); return new HashMap<>(); + } catch (InterruptedException e) { + throw new RuntimeException(e); } return members; } + private List getMembersWithRetries() throws GitLabApiException, InterruptedException { + int delay = INITIAL_DELAY_MS; + int attemptNb = 0; + final Sleeper sleeper = new Sleeper(); + GitLabApi gitLabApi = apiBuilder(this.getOwner(), serverName); + while (true) { + try { + return gitLabApi.getProjectApi().getAllMembers(projectPath); + } catch (GitLabApiException e) { + if (e.getHttpStatus() == 429) { + sleeper.sleep(delay); + delay *= 2; + attemptNb++; + if (attemptNb > MAX_RETRIES) { + throw e; + } + } else { + throw e; + } + } + } + } + public Long getProjectId() { return projectId; } diff --git a/src/main/java/io/jenkins/plugins/gitlabbranchsource/helpers/Sleeper.java b/src/main/java/io/jenkins/plugins/gitlabbranchsource/helpers/Sleeper.java new file mode 100644 index 00000000..66d49b7d --- /dev/null +++ b/src/main/java/io/jenkins/plugins/gitlabbranchsource/helpers/Sleeper.java @@ -0,0 +1,8 @@ +package io.jenkins.plugins.gitlabbranchsource.helpers; + +public class Sleeper { + + public void sleep(int millis) throws InterruptedException { + Thread.sleep(millis); + } +} diff --git a/src/test/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSourceTest.java b/src/test/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSourceTest.java index 9c261aaf..85a87509 100644 --- a/src/test/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSourceTest.java +++ b/src/test/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSourceTest.java @@ -8,25 +8,35 @@ import hudson.security.AccessControlled; import hudson.util.StreamTaskListener; import io.jenkins.plugins.gitlabbranchsource.helpers.GitLabHelper; +import io.jenkins.plugins.gitlabbranchsource.helpers.Sleeper; import io.jenkins.plugins.gitlabserverconfig.servers.GitLabServer; import io.jenkins.plugins.gitlabserverconfig.servers.GitLabServers; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import jenkins.branch.BranchSource; import jenkins.scm.api.SCMHead; +import jenkins.scm.api.SCMSourceOwner; import org.gitlab4j.api.GitLabApi; import org.gitlab4j.api.GitLabApiException; import org.gitlab4j.api.MergeRequestApi; import org.gitlab4j.api.ProjectApi; import org.gitlab4j.api.RepositoryApi; +import org.gitlab4j.api.models.AccessLevel; +import org.gitlab4j.api.models.Member; import org.gitlab4j.api.models.Project; import org.jenkinsci.plugins.workflow.multibranch.WorkflowMultiBranchProject; +import org.junit.After; +import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -39,6 +49,22 @@ public class GitLabSCMSourceTest { @ClassRule public static JenkinsRule j = new JenkinsRule(); + private MockedStatic utilities; + + private MockedConstruction sleeperMockedConstruction; + + @Before + public void setUp() { + utilities = Mockito.mockStatic(GitLabHelper.class); + sleeperMockedConstruction = Mockito.mockConstruction(Sleeper.class); + } + + @After + public void tearDown() { + utilities.close(); + sleeperMockedConstruction.close(); + } + @Test public void retrieveMRWithEmptyProjectSettings() throws GitLabApiException, IOException, InterruptedException { GitLabApi gitLabApi = Mockito.mock(GitLabApi.class); @@ -49,22 +75,103 @@ public void retrieveMRWithEmptyProjectSettings() throws GitLabApiException, IOEx Mockito.when(gitLabApi.getMergeRequestApi()).thenReturn(mrApi); Mockito.when(gitLabApi.getRepositoryApi()).thenReturn(repoApi); Mockito.when(projectApi.getProject(any())).thenReturn(new Project()); - try (MockedStatic utilities = Mockito.mockStatic(GitLabHelper.class)) { - utilities - .when(() -> GitLabHelper.apiBuilder(any(AccessControlled.class), anyString())) - .thenReturn(gitLabApi); - GitLabServers.get().addServer(new GitLabServer("", SERVER, "")); - GitLabSCMSourceBuilder sb = - new GitLabSCMSourceBuilder(SOURCE_ID, SERVER, "creds", "po", "group/project", "project"); - WorkflowMultiBranchProject project = j.createProject(WorkflowMultiBranchProject.class, PROJECT_NAME); - BranchSource source = new BranchSource(sb.build()); - source.getSource() - .setTraits(Arrays.asList(new BranchDiscoveryTrait(0), new OriginMergeRequestDiscoveryTrait(1))); - project.getSourcesList().add(source); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - final TaskListener listener = new StreamTaskListener(out, StandardCharsets.UTF_8); - Set scmHead = source.getSource().fetch(listener); - assertEquals(0, scmHead.size()); - } + utilities + .when(() -> GitLabHelper.apiBuilder(any(AccessControlled.class), anyString())) + .thenReturn(gitLabApi); + GitLabServers.get().addServer(new GitLabServer("", SERVER, "")); + GitLabSCMSourceBuilder sb = + new GitLabSCMSourceBuilder(SOURCE_ID, SERVER, "creds", "po", "group/project", "project"); + WorkflowMultiBranchProject project = j.createProject(WorkflowMultiBranchProject.class, PROJECT_NAME); + BranchSource source = new BranchSource(sb.build()); + source.getSource() + .setTraits(Arrays.asList(new BranchDiscoveryTrait(0), new OriginMergeRequestDiscoveryTrait(1))); + project.getSourcesList().add(source); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + final TaskListener listener = new StreamTaskListener(out, StandardCharsets.UTF_8); + Set scmHead = source.getSource().fetch(listener); + assertEquals(0, scmHead.size()); + } + + @Test + public void testGetMembersWithNoRetries() throws GitLabApiException { + GitLabApi gitLabApi = Mockito.mock(GitLabApi.class); + ProjectApi projectApi = Mockito.mock(ProjectApi.class); + Mockito.when(gitLabApi.getProjectApi()).thenReturn(projectApi); + Member mockMember = Mockito.mock(Member.class); + Mockito.when(mockMember.getUsername()).thenReturn("example.user"); + Mockito.when(mockMember.getAccessLevel()).thenReturn(AccessLevel.DEVELOPER); + SCMSourceOwner mockOwner = Mockito.mock(SCMSourceOwner.class); + Mockito.when(projectApi.getAllMembers("group/project")).thenReturn(List.of(mockMember)); + utilities + .when(() -> GitLabHelper.apiBuilder(any(AccessControlled.class), anyString())) + .thenReturn(gitLabApi); + GitLabServers.get().addServer(new GitLabServer("", SERVER, "")); + GitLabSCMSourceBuilder sb = + new GitLabSCMSourceBuilder(SOURCE_ID, SERVER, "creds", "po", "group/project", "project"); + GitLabSCMSource source = sb.build(); + source.setOwner(mockOwner); + assertEquals(Map.of("example.user", AccessLevel.DEVELOPER), source.getMembers()); + Sleeper sleeper = sleeperMockedConstruction.constructed().get(0); + Mockito.verifyNoInteractions(sleeper); + } + + @Test + public void testGetMembersWithAllRetries() throws GitLabApiException, InterruptedException { + GitLabApi gitLabApi = Mockito.mock(GitLabApi.class); + ProjectApi projectApi = Mockito.mock(ProjectApi.class); + Mockito.when(gitLabApi.getProjectApi()).thenReturn(projectApi); + SCMSourceOwner mockOwner = Mockito.mock(SCMSourceOwner.class); + GitLabApiException rateLimitException = new GitLabApiException("Rate limit", 429); + Mockito.when(projectApi.getAllMembers("group/project")).thenThrow(rateLimitException); + utilities + .when(() -> GitLabHelper.apiBuilder(any(AccessControlled.class), anyString())) + .thenReturn(gitLabApi); + GitLabServers.get().addServer(new GitLabServer("", SERVER, "")); + GitLabSCMSourceBuilder sb = + new GitLabSCMSourceBuilder(SOURCE_ID, SERVER, "creds", "po", "group/project", "project"); + GitLabSCMSource source = sb.build(); + source.setOwner(mockOwner); + assertEquals(Map.of(), source.getMembers()); + Sleeper sleeper = sleeperMockedConstruction.constructed().get(0); + Mockito.verify(sleeper, Mockito.times(1)).sleep(5000); + Mockito.verify(sleeper, Mockito.times(1)).sleep(10000); + Mockito.verify(sleeper, Mockito.times(1)).sleep(20000); + Mockito.verify(sleeper, Mockito.times(1)).sleep(40000); + Mockito.verify(sleeper, Mockito.times(1)).sleep(80000); + Mockito.verify(sleeper, Mockito.times(1)).sleep(160000); + Mockito.verifyNoMoreInteractions(sleeper); + } + + @Test + public void testGetMembersWithSomeRetries() throws GitLabApiException, InterruptedException { + GitLabApi gitLabApi = Mockito.mock(GitLabApi.class); + ProjectApi projectApi = Mockito.mock(ProjectApi.class); + Mockito.when(gitLabApi.getProjectApi()).thenReturn(projectApi); + GitLabApiException rateLimitException = new GitLabApiException("Rate limit", 429); + Member mockMember = Mockito.mock(Member.class); + Mockito.when(mockMember.getUsername()).thenReturn("example.user"); + Mockito.when(mockMember.getAccessLevel()).thenReturn(AccessLevel.DEVELOPER); + SCMSourceOwner mockOwner = Mockito.mock(SCMSourceOwner.class); + AtomicInteger counter = new AtomicInteger(); + Mockito.when(projectApi.getAllMembers("group/project")).thenAnswer((input) -> { + if (counter.getAndIncrement() < 3) { + throw rateLimitException; + } + return List.of(mockMember); + }); + utilities + .when(() -> GitLabHelper.apiBuilder(any(AccessControlled.class), anyString())) + .thenReturn(gitLabApi); + GitLabServers.get().addServer(new GitLabServer("", SERVER, "")); + GitLabSCMSourceBuilder sb = + new GitLabSCMSourceBuilder(SOURCE_ID, SERVER, "creds", "po", "group/project", "project"); + GitLabSCMSource source = sb.build(); + source.setOwner(mockOwner); + assertEquals(Map.of("example.user", AccessLevel.DEVELOPER), source.getMembers()); + Sleeper sleeper = sleeperMockedConstruction.constructed().get(0); + Mockito.verify(sleeper, Mockito.times(1)).sleep(5000); + Mockito.verify(sleeper, Mockito.times(1)).sleep(10000); + Mockito.verify(sleeper, Mockito.times(1)).sleep(20000); + Mockito.verifyNoMoreInteractions(sleeper); } } From a4a31b006fb7dfd25af5893ffef6c22160a15bbb Mon Sep 17 00:00:00 2001 From: The-Jonsey Date: Fri, 14 Nov 2025 16:08:17 +0000 Subject: [PATCH 2/3] Handle GitLabApiException nested in RuntimeException --- .../gitlabbranchsource/GitLabSCMSource.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java b/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java index 9bf8a10b..48e930d0 100644 --- a/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java +++ b/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java @@ -251,21 +251,31 @@ private List getMembersWithRetries() throws GitLabApiException, Interrup while (true) { try { return gitLabApi.getProjectApi().getAllMembers(projectPath); - } catch (GitLabApiException e) { - if (e.getHttpStatus() == 429) { + } catch (GitLabApiException | RuntimeException e) { + if (isRateLimitException(e)) { sleeper.sleep(delay); delay *= 2; attemptNb++; if (attemptNb > MAX_RETRIES) { throw e; } - } else { - throw e; + continue; } + throw e; } } } + private static boolean isRateLimitException(Exception e) { + if (e instanceof GitLabApiException) { + return ((GitLabApiException) e).getHttpStatus() == 429; + } else if (e.getCause() != null && e.getCause().getClass().isAssignableFrom(GitLabApiException.class)) { + GitLabApiException cause = (GitLabApiException) e.getCause(); + return cause.getHttpStatus() == 429; + } + return false; + } + public Long getProjectId() { return projectId; } From f3ccaddcf9debfeb6e59b145716a3e104083bc4d Mon Sep 17 00:00:00 2001 From: The-Jonsey Date: Fri, 14 Nov 2025 16:23:49 +0000 Subject: [PATCH 3/3] Resolve spotless issues --- .../jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java b/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java index 48e930d0..15f8bc25 100644 --- a/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java +++ b/src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java @@ -270,8 +270,8 @@ private static boolean isRateLimitException(Exception e) { if (e instanceof GitLabApiException) { return ((GitLabApiException) e).getHttpStatus() == 429; } else if (e.getCause() != null && e.getCause().getClass().isAssignableFrom(GitLabApiException.class)) { - GitLabApiException cause = (GitLabApiException) e.getCause(); - return cause.getHttpStatus() == 429; + GitLabApiException cause = (GitLabApiException) e.getCause(); + return cause.getHttpStatus() == 429; } return false; }