1010use DateTimeImmutable ;
1111use Psr \Log \LoggerInterface ;
1212use RuntimeException ;
13+ use Symfony \Contracts \HttpClient \Exception \ClientExceptionInterface ;
14+ use Symfony \Contracts \HttpClient \Exception \ServerExceptionInterface ;
15+ use Symfony \Contracts \HttpClient \Exception \TransportExceptionInterface ;
1316use Symfony \Contracts \HttpClient \HttpClientInterface ;
1417use Symfony \Contracts \HttpClient \ResponseInterface ;
1518
1619class GithubApiClient
1720{
18- private const string BASE_URL = 'https://api.github.com ' ;
21+ private const string BASE_URL = 'https://api.github.com ' ;
22+ private const float DEFAULT_TIMEOUT_SECONDS = 15.0 ;
23+ private const float DEFAULT_MAX_DURATION_SECONDS = 30.0 ;
24+ private const int DEFAULT_GET_RETRY_ATTEMPTS = 3 ;
25+ private const int RETRY_BASE_DELAY_MS = 200 ;
26+ private const int RETRY_MAX_DELAY_MS = 2_000 ;
1927
2028 public function __construct (
2129 private readonly HttpClientInterface $ httpClient ,
@@ -32,7 +40,8 @@ public function getIssuesWithLabel(string $owner, string $repo, string $token, s
3240 $ page = 1 ;
3341
3442 do {
35- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues " , $ token , [
43+ /** @var list<array<string, mixed>> $data */
44+ $ data = $ this ->requestJsonWithRetry ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues " , $ token , [
3645 'query ' => [
3746 'labels ' => $ labelName ,
3847 'state ' => 'open ' ,
@@ -41,9 +50,6 @@ public function getIssuesWithLabel(string $owner, string $repo, string $token, s
4150 ],
4251 ]);
4352
44- /** @var list<array<string, mixed>> $data */
45- $ data = $ response ->toArray ();
46-
4753 foreach ($ data as $ issueData ) {
4854 if (array_key_exists ('pull_request ' , $ issueData )) {
4955 continue ;
@@ -74,16 +80,14 @@ public function getIssueComments(string $owner, string $repo, string $token, int
7480 $ page = 1 ;
7581
7682 do {
77- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/comments " , $ token , [
83+ /** @var list<array<string, mixed>> $data */
84+ $ data = $ this ->requestJsonWithRetry ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/comments " , $ token , [
7885 'query ' => [
7986 'per_page ' => 100 ,
8087 'page ' => $ page ,
8188 ],
8289 ]);
8390
84- /** @var list<array<string, mixed>> $data */
85- $ data = $ response ->toArray ();
86-
8791 foreach ($ data as $ commentData ) {
8892 /** @var array<string, mixed>|null $userData */
8993 $ userData = $ commentData ['user ' ] ?? null ;
@@ -104,9 +108,10 @@ public function getIssueComments(string $owner, string $repo, string $token, int
104108
105109 public function addLabelToIssue (string $ owner , string $ repo , string $ token , int $ issueNumber , string $ labelName ): void
106110 {
107- $ this ->request ('POST ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/labels " , $ token , [
111+ $ response = $ this ->request ('POST ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/labels " , $ token , [
108112 'json ' => ['labels ' => [$ labelName ]],
109113 ]);
114+ $ response ->getContent ();
110115
111116 $ this ->logger ->info ('Added label to issue ' , [
112117 'owner ' => $ owner ,
@@ -121,15 +126,16 @@ public function removeLabelFromIssue(string $owner, string $repo, string $token,
121126 $ encodedLabel = rawurlencode ($ labelName );
122127
123128 try {
124- $ this ->request ('DELETE ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/labels/ {$ encodedLabel }" , $ token );
129+ $ response = $ this ->request ('DELETE ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/labels/ {$ encodedLabel }" , $ token );
130+ $ response ->getContent ();
125131
126132 $ this ->logger ->info ('Removed label from issue ' , [
127133 'owner ' => $ owner ,
128134 'repo ' => $ repo ,
129135 'issue ' => $ issueNumber ,
130136 'label ' => $ labelName ,
131137 ]);
132- } catch (\ Symfony \ Component \ HttpClient \ Exception \ ClientException $ e ) {
138+ } catch (ClientExceptionInterface $ e ) {
133139 if ($ e ->getResponse ()->getStatusCode () === 404 ) {
134140 $ this ->logger ->debug ('Label not present on issue, nothing to remove ' , [
135141 'issue ' => $ issueNumber ,
@@ -146,20 +152,21 @@ public function removeLabelFromIssue(string $owner, string $repo, string $token,
146152 public function createLabel (string $ owner , string $ repo , string $ token , string $ labelName , string $ color , string $ description = '' ): void
147153 {
148154 try {
149- $ this ->request ('POST ' , "/repos/ {$ owner }/ {$ repo }/labels " , $ token , [
155+ $ response = $ this ->request ('POST ' , "/repos/ {$ owner }/ {$ repo }/labels " , $ token , [
150156 'json ' => [
151157 'name ' => $ labelName ,
152158 'color ' => $ color ,
153159 'description ' => $ description ,
154160 ],
155161 ]);
162+ $ response ->getContent ();
156163
157164 $ this ->logger ->info ('Created label ' , [
158165 'owner ' => $ owner ,
159166 'repo ' => $ repo ,
160167 'label ' => $ labelName ,
161168 ]);
162- } catch (\ Symfony \ Component \ HttpClient \ Exception \ ClientException $ e ) {
169+ } catch (ClientExceptionInterface $ e ) {
163170 if ($ e ->getResponse ()->getStatusCode () === 422 ) {
164171 $ this ->logger ->debug ('Label already exists ' , [
165172 'owner ' => $ owner ,
@@ -176,9 +183,10 @@ public function createLabel(string $owner, string $repo, string $token, string $
176183
177184 public function postIssueComment (string $ owner , string $ repo , string $ token , int $ issueNumber , string $ body ): void
178185 {
179- $ this ->request ('POST ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/comments " , $ token , [
186+ $ response = $ this ->request ('POST ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/comments " , $ token , [
180187 'json ' => ['body ' => $ body ],
181188 ]);
189+ $ response ->getContent ();
182190
183191 $ this ->logger ->info ('Posted comment on issue ' , [
184192 'owner ' => $ owner ,
@@ -190,10 +198,8 @@ public function postIssueComment(string $owner, string $repo, string $token, int
190198
191199 public function getAuthenticatedUserLogin (string $ token ): string
192200 {
193- $ response = $ this ->request ('GET ' , '/user ' , $ token );
194-
195201 /** @var array<string, mixed> $data */
196- $ data = $ response -> toArray ( );
202+ $ data = $ this -> requestJsonWithRetry ( ' GET ' , ' /user ' , $ token );
197203
198204 return $ this ->stringVal ($ data , 'login ' );
199205 }
@@ -204,17 +210,15 @@ public function findOpenPullRequestForIssue(string $owner, string $repo, string
204210 $ page = 1 ;
205211
206212 do {
207- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/pulls " , $ token , [
213+ /** @var list<array<string, mixed>> $data */
214+ $ data = $ this ->requestJsonWithRetry ('GET ' , "/repos/ {$ owner }/ {$ repo }/pulls " , $ token , [
208215 'query ' => [
209216 'state ' => 'open ' ,
210217 'per_page ' => 100 ,
211218 'page ' => $ page ,
212219 ],
213220 ]);
214221
215- /** @var list<array<string, mixed>> $data */
216- $ data = $ response ->toArray ();
217-
218222 foreach ($ data as $ prData ) {
219223 $ body = $ this ->stringVal ($ prData , 'body ' );
220224 if (str_contains ($ body , $ closesPattern )) {
@@ -247,16 +251,14 @@ public function getPullRequestReviewComments(string $owner, string $repo, string
247251 $ page = 1 ;
248252
249253 do {
250- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/pulls/ {$ prNumber }/comments " , $ token , [
254+ /** @var list<array<string, mixed>> $data */
255+ $ data = $ this ->requestJsonWithRetry ('GET ' , "/repos/ {$ owner }/ {$ repo }/pulls/ {$ prNumber }/comments " , $ token , [
251256 'query ' => [
252257 'per_page ' => 100 ,
253258 'page ' => $ page ,
254259 ],
255260 ]);
256261
257- /** @var list<array<string, mixed>> $data */
258- $ data = $ response ->toArray ();
259-
260262 foreach ($ data as $ commentData ) {
261263 /** @var array<string, mixed>|null $userData */
262264 $ userData = $ commentData ['user ' ] ?? null ;
@@ -281,16 +283,14 @@ public function getPullRequestLatestCommitCommittedAt(string $owner, string $rep
281283 $ page = 1 ;
282284
283285 do {
284- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/pulls/ {$ prNumber }/commits " , $ token , [
286+ /** @var list<array<string, mixed>> $data */
287+ $ data = $ this ->requestJsonWithRetry ('GET ' , "/repos/ {$ owner }/ {$ repo }/pulls/ {$ prNumber }/commits " , $ token , [
285288 'query ' => [
286289 'per_page ' => 100 ,
287290 'page ' => $ page ,
288291 ],
289292 ]);
290293
291- /** @var list<array<string, mixed>> $data */
292- $ data = $ response ->toArray ();
293-
294294 foreach ($ data as $ commitData ) {
295295 /** @var array<string, mixed>|null $commitMetadata */
296296 $ commitMetadata = $ commitData ['commit ' ] ?? null ;
@@ -324,18 +324,16 @@ public function getPullRequestLatestCommitCommittedAt(string $owner, string $rep
324324 public function getIssueByNumber (string $ owner , string $ repo , string $ token , int $ issueNumber ): ?RawGithubIssue
325325 {
326326 try {
327- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }" , $ token );
328- } catch (\Symfony \Component \HttpClient \Exception \ClientException $ e ) {
327+ /** @var array<string, mixed> $data */
328+ $ data = $ this ->requestJsonWithRetry ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }" , $ token );
329+ } catch (ClientExceptionInterface $ e ) {
329330 if ($ e ->getResponse ()->getStatusCode () === 404 ) {
330331 return null ;
331332 }
332333
333334 throw $ e ;
334335 }
335336
336- /** @var array<string, mixed> $data */
337- $ data = $ response ->toArray ();
338-
339337 if (array_key_exists ('pull_request ' , $ data )) {
340338 return null ;
341339 }
@@ -350,10 +348,8 @@ public function getIssueByNumber(string $owner, string $repo, string $token, int
350348
351349 public function getIssueState (string $ owner , string $ repo , string $ token , int $ issueNumber ): string
352350 {
353- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }" , $ token );
354-
355351 /** @var array<string, mixed> $data */
356- $ data = $ response -> toArray ( );
352+ $ data = $ this -> requestJsonWithRetry ( ' GET ' , " /repos/ { $ owner } / { $ repo } /issues/ { $ issueNumber }" , $ token );
357353
358354 return $ this ->stringVal ($ data , 'state ' );
359355 }
@@ -364,16 +360,14 @@ public function getLabelAddedAt(string $owner, string $repo, string $token, int
364360 $ page = 1 ;
365361
366362 do {
367- $ response = $ this ->request ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/events " , $ token , [
363+ /** @var list<array<string, mixed>> $data */
364+ $ data = $ this ->requestJsonWithRetry ('GET ' , "/repos/ {$ owner }/ {$ repo }/issues/ {$ issueNumber }/events " , $ token , [
368365 'query ' => [
369366 'per_page ' => 100 ,
370367 'page ' => $ page ,
371368 ],
372369 ]);
373370
374- /** @var list<array<string, mixed>> $data */
375- $ data = $ response ->toArray ();
376-
377371 foreach ($ data as $ event ) {
378372 $ eventType = $ this ->stringVal ($ event , 'event ' );
379373 /** @var array<string, mixed>|null $labelData */
@@ -469,6 +463,14 @@ private function parseDateTimeImmutable(string $isoDate): DateTimeImmutable
469463 */
470464 private function request (string $ method , string $ path , string $ token , array $ options = []): ResponseInterface
471465 {
466+ if (!array_key_exists ('timeout ' , $ options )) {
467+ $ options ['timeout ' ] = self ::DEFAULT_TIMEOUT_SECONDS ;
468+ }
469+
470+ if (!array_key_exists ('max_duration ' , $ options )) {
471+ $ options ['max_duration ' ] = self ::DEFAULT_MAX_DURATION_SECONDS ;
472+ }
473+
472474 /** @var array<string, string> $existingHeaders */
473475 $ existingHeaders = is_array ($ options ['headers ' ] ?? null ) ? $ options ['headers ' ] : [];
474476 $ options ['headers ' ] = array_merge ($ existingHeaders , [
@@ -479,4 +481,89 @@ private function request(string $method, string $path, string $token, array $opt
479481
480482 return $ this ->httpClient ->request ($ method , self ::BASE_URL . $ path , $ options );
481483 }
484+
485+ /**
486+ * @param array<string, mixed> $options
487+ *
488+ * @return array<mixed>
489+ */
490+ private function requestJsonWithRetry (string $ method , string $ path , string $ token , array $ options = []): array
491+ {
492+ $ maxAttempts = strtoupper ($ method ) === 'GET ' ? self ::DEFAULT_GET_RETRY_ATTEMPTS : 1 ;
493+ $ attempt = 0 ;
494+
495+ while (true ) {
496+ ++$ attempt ;
497+
498+ try {
499+ $ response = $ this ->request ($ method , $ path , $ token , $ options );
500+
501+ /** @var array<mixed> $data */
502+ $ data = $ response ->toArray ();
503+
504+ return $ data ;
505+ } catch (TransportExceptionInterface $ e ) {
506+ if ($ attempt >= $ maxAttempts ) {
507+ throw $ e ;
508+ }
509+
510+ $ this ->logger ->warning ('GitHub API transport error, retrying ' , [
511+ 'method ' => $ method ,
512+ 'path ' => $ path ,
513+ 'attempt ' => $ attempt ,
514+ 'error ' => $ e ->getMessage (),
515+ ]);
516+
517+ $ this ->sleepBeforeRetry ($ attempt );
518+ } catch (ServerExceptionInterface $ e ) {
519+ if ($ attempt >= $ maxAttempts ) {
520+ throw $ e ;
521+ }
522+
523+ $ this ->logger ->warning ('GitHub API server error, retrying ' , [
524+ 'method ' => $ method ,
525+ 'path ' => $ path ,
526+ 'attempt ' => $ attempt ,
527+ 'statusCode ' => $ e ->getResponse ()->getStatusCode (),
528+ 'error ' => $ e ->getMessage (),
529+ ]);
530+
531+ $ this ->sleepBeforeRetry ($ attempt );
532+ } catch (ClientExceptionInterface $ e ) {
533+ $ statusCode = $ e ->getResponse ()->getStatusCode ();
534+
535+ if ($ attempt < $ maxAttempts && $ this ->shouldRetryClientStatusCode ($ statusCode )) {
536+ $ this ->logger ->warning ('GitHub API client error, retrying ' , [
537+ 'method ' => $ method ,
538+ 'path ' => $ path ,
539+ 'attempt ' => $ attempt ,
540+ 'statusCode ' => $ statusCode ,
541+ 'error ' => $ e ->getMessage (),
542+ ]);
543+
544+ $ this ->sleepBeforeRetry ($ attempt );
545+ continue ;
546+ }
547+
548+ throw $ e ;
549+ }
550+ }
551+ }
552+
553+ private function shouldRetryClientStatusCode (int $ statusCode ): bool
554+ {
555+ return $ statusCode === 429 ;
556+ }
557+
558+ private function sleepBeforeRetry (int $ attempt ): void
559+ {
560+ $ delayMs = (int ) min (
561+ self ::RETRY_MAX_DELAY_MS ,
562+ self ::RETRY_BASE_DELAY_MS * (2 ** ($ attempt - 1 )),
563+ );
564+
565+ $ jitterMs = random_int (0 , max (1 , (int ) ($ delayMs * 0.2 )));
566+
567+ usleep (($ delayMs + $ jitterMs ) * 1000 );
568+ }
482569}
0 commit comments