Skip to content

Commit c21d0c1

Browse files
authored
[JENKINS-72047] Retry GitHub API call on error with random delay (#20)
This change comes with - 3 tunable system properties. - retry API call with a random delay between retries up to a limit of 60 calls - A trace ID added to logs in order to make reviewing debug logs easier. The trace ID is unique per processed SCM event. See also -------- * [JENKINS-72047][JENKINS-72047] scm-filter-jervis gives up after 1 API request to GitHub which can lead to missed webhooks [JENKINS-72047]: https://issues.jenkins.io/browse/JENKINS-72047
1 parent f67873b commit c21d0c1

File tree

1 file changed

+131
-26
lines changed

1 file changed

+131
-26
lines changed

src/main/groovy/net/gleske/scmfilter/impl/trait/JervisFilterTrait.groovy

Lines changed: 131 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
2020
DEALINGS IN THE SOFTWARE.
2121
*/
22-
package net.gleske.scmfilter.impl.trait;
22+
package net.gleske.scmfilter.impl.trait
2323

24+
import static net.gleske.jervis.remotes.SimpleRestService.objToJson
2425
import static net.gleske.jervis.tools.AutoRelease.getScriptFromTemplate
26+
import static net.gleske.jervis.tools.SecurityIO.sha256Sum
2527
import net.gleske.jervis.remotes.GitHubGraphQL
2628
import net.gleske.jervis.tools.YamlOperator
2729
import net.gleske.scmfilter.credential.GraphQLTokenCredential
@@ -45,12 +47,36 @@ import org.jenkinsci.Symbol
4547
import org.jenkinsci.plugins.github_branch_source.GitHubSCMSource
4648
import org.kohsuke.stapler.DataBoundConstructor
4749

48-
import groovy.text.SimpleTemplateEngine
49-
import java.util.logging.Level
50+
import java.time.Instant
51+
import java.util.concurrent.ThreadLocalRandom
5052
import java.util.logging.Logger
5153
import java.util.regex.Pattern
5254

55+
/**
56+
This trait is responsible for determining if a branch, PR, or Tag is
57+
buildable in a MultiBranch pipeline.
5358
59+
This class has tunable properties for making network requests to GitHub. It
60+
will introduce a random delay (min 1000ms to max 3000ms) between retrying.
61+
By default it will retry GitHub API requests up to 60 times. Time-wise
62+
retrying can take anywhere from 1-3 minutes based on the randomness before
63+
this class gives up raising an exception.
64+
65+
To change minimum range of random delay, start Jenkins with the following
66+
property. Value is an Integer (milliseconds).
67+
68+
-Dnet.gleske.scmfilter.impl.trait.JervisFilterTrait.minRetryInterval=1000
69+
70+
To change maximum range of random delay, start Jenkins with the following
71+
property. Value is an Integer (milliseconds).
72+
73+
-Dnet.gleske.scmfilter.impl.trait.JervisFilterTrait.maxRetryInterval=3000
74+
75+
To change the amount of times retry is attempted, start Jenkins with the
76+
following property. Value is an Integer (count of retrying).
77+
78+
-Dnet.gleske.scmfilter.impl.trait.JervisFilterTrait.retryLimit=60
79+
*/
5480
public class JervisFilterTrait extends SCMSourceTrait {
5581

5682
private static final long serialVersionUID = 1L
@@ -75,6 +101,87 @@ public class JervisFilterTrait extends SCMSourceTrait {
75101

76102
@NonNull
77103
private final String yamlFileName
104+
105+
/**
106+
Get .jervis.yml file from GitHub retrying with a random delay if GitHub
107+
request fails.
108+
109+
@param options Instead of parameters you pass parameters by name. [client, query, yamlFiles, log_trace_id]
110+
@return A response from GitHub with .jervis.yml with keys [yamlFile, yamlText]. Key-values can be Strings, empty string or null.
111+
*/
112+
private Map getYamlWithRetry(Map options) throws Exception {
113+
// method options
114+
GitHubGraphQL github = options.client
115+
String query = options.query
116+
List yamlFiles = options.yamlFiles
117+
String log_trace_id = options.log_trace_id
118+
119+
// system properties
120+
Integer minInterval = Integer.getInteger(JervisFilterTrait.name + ".minRetryInterval", 1000)
121+
Integer maxInterval = Integer.getInteger(JervisFilterTrait.name + ".maxRetryInterval", 3000) + 1
122+
Integer retryLimit = Integer.getInteger(JervisFilterTrait.name + ".retryLimit", 30)
123+
124+
// internal values
125+
List errors = []
126+
String yamlText = ''
127+
String yamlFile = ''
128+
Integer retryCount = 0
129+
Map response = [:]
130+
while({->
131+
if(retryCount > 0) {
132+
LOGGER.finer("(trace-${log_trace_id}) Retrying GraphQL after failure (retryCount ${retryCount})")
133+
}
134+
if(retryCount > retryLimit && errors) {
135+
LOGGER.finer("(trace-${log_trace_id}) Retry limit reached with GQL errors ${objToJson(errors: errors)}")
136+
throw new Exception("(trace-${log_trace_id}) Retry limit reached with GQL errors ${objToJson(errors: errors)}")
137+
} else {
138+
errors = []
139+
}
140+
try {
141+
response = github.sendGQL(query)
142+
} catch(Exception httpError) {
143+
if(retryCount > retryLimit) {
144+
LOGGER.finer("(trace-${log_trace_id}) GraphQL HTTP Error: ${httpError.getMessage()}")
145+
throw httpError
146+
}
147+
// random delay for sleep
148+
sleep(ThreadLocalRandom.current().nextLong(minInterval, maxInterval))
149+
retryCount++
150+
// retry while loop
151+
return true
152+
}
153+
154+
// look for GQL errors
155+
if('errors' in response.keySet()) {
156+
errors = response.errors
157+
// random delay for sleep
158+
sleep(ThreadLocalRandom.current().nextLong(minInterval, maxInterval))
159+
retryCount++
160+
// retry while loop
161+
return true
162+
}
163+
164+
// get data from response
165+
response?.data?.repository?.with { Map repoData ->
166+
yamlFiles.eachWithIndex { yamlFileEntry, i ->
167+
if(yamlText) {
168+
// exit eachWithIndex loop
169+
return
170+
}
171+
yamlText = (repoData?.get("jervisYaml${i}".toString())?.text?.trim())
172+
if(yamlText) {
173+
yamlFile = yamlFiles[i]
174+
}
175+
}
176+
}
177+
// exit the do-while loop
178+
return false
179+
}()) continue
180+
181+
// return Map
182+
[yamlFile: yamlFile, yamlText: yamlText]
183+
}
184+
78185
/**
79186
Returns the YAML file name to search for filters.
80187
@@ -86,10 +193,10 @@ public class JervisFilterTrait extends SCMSourceTrait {
86193

87194
@DataBoundConstructor
88195
JervisFilterTrait(@CheckForNull String yamlFileName) {
89-
this.yamlFileName = StringUtils.defaultIfBlank(yamlFileName, DEFAULT_YAML_FILE);
196+
this.yamlFileName = StringUtils.defaultIfBlank(yamlFileName, DEFAULT_YAML_FILE)
90197
}
91198

92-
private static shouldExclude(def filters_obj, String target_ref) {
199+
private static shouldExclude(def filters_obj, String target_ref, String log_trace_id) {
93200
List filters = []
94201
String filter_type = 'only'
95202
if(filters_obj instanceof List) {
@@ -105,7 +212,7 @@ public class JervisFilterTrait extends SCMSourceTrait {
105212
}
106213
}
107214
if(!filters) {
108-
LOGGER.fine("Malformed filter found on git reference ${target_ref} so we will allow by default")
215+
LOGGER.fine("(trace-${log_trace_id}) Malformed filter found on git reference ${target_ref} so we will allow by default")
109216
return false
110217
}
111218
String regex = filters.collect {
@@ -129,6 +236,13 @@ public class JervisFilterTrait extends SCMSourceTrait {
129236
// wrong type of SCM source so skipping without excluding
130237
return false
131238
}
239+
// ISO-8601 instant timestamp which provides uniqueness
240+
// http://www.iso.org/iso/home/standards/iso8601.htm
241+
String log_trace_timestamp = Instant.now().toString()
242+
String trace_target = (head in ChangeRequestSCMHead) ? head.target.name : head.name
243+
// Unique trace ID so that associated log messages can be
244+
// followed in Jenkins debug logs
245+
String log_trace_id = sha256Sum(log_trace_timestamp + source.repoOwner + source.repository + trace_target)
132246
def github = new GitHubGraphQL()
133247
// set credentials for GraphQL API interaction
134248
github.credential = new GraphQLTokenCredential(source.owner, source.credentialsId)
@@ -152,44 +266,35 @@ public class JervisFilterTrait extends SCMSourceTrait {
152266
// pull request
153267
binding['git_ref'] = "refs/pull/${head.id}/head"
154268
target_ref = head.target.name
155-
LOGGER.fine("Scanning pull request ${head.name}.")
269+
LOGGER.fine("(trace-${log_trace_id}) Scanning pull request ${head.name}.")
156270
}
157271
else if(head instanceof TagSCMHead) {
158272
// tag
159273
binding['git_ref'] = "refs/tags/${head.name}"
160274
target_ref = head.name
161-
LOGGER.fine("Scanning tag ${head.name}.")
275+
LOGGER.fine("(trace-${log_trace_id}) Scanning tag ${head.name}.")
162276
}
163277
else {
164278
// branch
165279
binding['git_ref'] = "refs/heads/${head.name}"
166280
target_ref = head.name
167-
LOGGER.fine("Scanning branch ${head.name}.")
281+
LOGGER.fine("(trace-${log_trace_id}) Scanning branch ${head.name}.")
168282
}
169283

170284
String graphql_query = getScriptFromTemplate(graphql_expr_template, binding)
171-
LOGGER.finer("GraphQL query for target ref ${target_ref}:\n${graphql_query}")
172-
Map response = github.sendGQL(graphql_query)
173-
String yamlText = ''
285+
LOGGER.finer("(trace-${log_trace_id}) GraphQL query for target ref ${target_ref}:\n${graphql_query}")
174286
// try to get all requested yaml files from the comma
175287
// separated paths provided by the admin configuration
176-
String yamlFile = ''
177-
response?.get('data')?.get('repository')?.with { Map repoData ->
178-
for(int i = 0; i < yamlFiles.size(); i++) {
179-
yamlText = (repoData?.get("jervisYaml${i}".toString())?.get('text')?.trim())
180-
if(yamlText) {
181-
yamlFile = yamlFiles[i]
182-
break
183-
}
184-
}
185-
}
288+
Map response = getYamlWithRetry(client: github, query: graphql_query, yamlFiles: yamlFiles, log_trace_id: log_trace_id)
289+
String yamlFile = response.yamlFile ?: ''
290+
String yamlText = response.yamlText ?: ''
186291

187292
if(!yamlText) {
188293
// could not find YAML or file was empty so should not build
189-
LOGGER.finer("On target ref ${target_ref}, could not find yaml file(s): ${yamlFileName}")
294+
LOGGER.finer("(trace-${log_trace_id}) On target ref ${target_ref}, could not find yaml file(s): ${yamlFileName}")
190295
return true
191296
}
192-
LOGGER.fine("On target ref ${target_ref}, found ${yamlFile}:\n${['='*80, yamlText, '='*80].join('\n')}\nEND YAML FILE")
297+
LOGGER.fine("(trace-${log_trace_id}) On target ref ${target_ref}, found ${yamlFile}:\n${['='*80, yamlText, '='*80].join('\n')}\nEND YAML FILE")
193298

194299
// parse the YAML for filtering
195300
Map jervis_yaml = YamlOperator.loadYamlFrom(yamlText)
@@ -199,15 +304,15 @@ public class JervisFilterTrait extends SCMSourceTrait {
199304
// allow all by default
200305
return false
201306
}
202-
return shouldExclude(jervis_yaml['tags'], target_ref)
307+
return shouldExclude(jervis_yaml['tags'], target_ref, log_trace_id)
203308
}
204309
else {
205310
// branch or pull request
206311
if(!('branches' in jervis_yaml)) {
207312
// allow all by default
208313
return false
209314
}
210-
return shouldExclude(jervis_yaml['branches'], target_ref)
315+
return shouldExclude(jervis_yaml['branches'], target_ref, log_trace_id)
211316
}
212317
}
213318
})

0 commit comments

Comments
 (0)