Skip to content

Commit 62d2c78

Browse files
committed
chore: handle gitlab rate limit errors better
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 09071ab commit 62d2c78

12 files changed

Lines changed: 182 additions & 8 deletions
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Logger } from '@crowd/logging'
2+
import { RateLimitError } from '@crowd/types'
3+
4+
const getHeader = (
5+
headers: Record<string, string> | undefined,
6+
name: string,
7+
): string | undefined => {
8+
if (!headers) return undefined
9+
10+
if (headers[name]) return headers[name]
11+
12+
const lowerName = name.toLowerCase()
13+
if (headers[lowerName]) return headers[lowerName]
14+
15+
const headerKey = Object.keys(headers).find((key) => key.toLowerCase() === lowerName)
16+
return headerKey ? headers[headerKey] : undefined
17+
}
18+
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
export const handleGitlabError = (err: any, endpoint: string, logger: Logger) => {
21+
// Check if this is a rate limit error (429)
22+
if (err && err.response && err.response.status === 429) {
23+
logger.warn('GitLab API rate limit exceeded')
24+
let rateLimitResetSeconds = 60 // Default to 60 seconds if no header is present
25+
26+
// https://docs.gitlab.com/administration/settings/user_and_ip_rate_limits/#use-a-custom-rate-limit-response
27+
28+
// Retry-After header in seconds
29+
const retryAfter = getHeader(err.response.headers, 'retry-after')
30+
if (retryAfter) {
31+
rateLimitResetSeconds = parseInt(retryAfter, 10)
32+
}
33+
// RateLimit-Reset header (Unix timestamp)
34+
else {
35+
const rateLimitReset = getHeader(err.response.headers, 'ratelimit-reset')
36+
if (rateLimitReset) {
37+
const resetTimestamp = parseInt(rateLimitReset, 10)
38+
const now = Math.floor(Date.now() / 1000)
39+
rateLimitResetSeconds = Math.max(resetTimestamp - now, 0) + 5 // Add 5 second buffer
40+
}
41+
}
42+
43+
logger.warn(
44+
{
45+
rateLimitResetSeconds,
46+
endpoint,
47+
remainingRequests: getHeader(err.response.headers, 'ratelimit-remaining'),
48+
resetTime: getHeader(err.response.headers, 'ratelimit-reset'),
49+
},
50+
'Rate limit details',
51+
)
52+
53+
return new RateLimitError(rateLimitResetSeconds, endpoint, err)
54+
}
55+
56+
// Check if this is a 403 error which can also indicate rate limiting
57+
if (err && err.response && err.response.status === 403) {
58+
const errorMessage = err.response.data?.message || err.message || ''
59+
if (errorMessage.toLowerCase().includes('rate limit')) {
60+
logger.warn('GitLab API rate limit exceeded (403)')
61+
let rateLimitResetSeconds = 60
62+
63+
const retryAfter = getHeader(err.response.headers, 'retry-after')
64+
if (retryAfter) {
65+
rateLimitResetSeconds = parseInt(retryAfter, 10)
66+
} else {
67+
const rateLimitReset = getHeader(err.response.headers, 'ratelimit-reset')
68+
if (rateLimitReset) {
69+
const resetTimestamp = parseInt(rateLimitReset, 10)
70+
const now = Math.floor(Date.now() / 1000)
71+
rateLimitResetSeconds = Math.max(resetTimestamp - now, 0) + 5
72+
}
73+
}
74+
75+
return new RateLimitError(rateLimitResetSeconds, endpoint, err)
76+
}
77+
}
78+
79+
logger.error(err, `Error while calling GitLab API endpoint: ${endpoint}`)
80+
return err
81+
}
82+
83+
export const isApproachingRateLimit = (
84+
headers: Record<string, string> | undefined,
85+
threshold = 10,
86+
): boolean => {
87+
if (!headers) return false
88+
89+
const remaining = getHeader(headers, 'ratelimit-remaining')
90+
if (remaining) {
91+
const remainingCount = parseInt(remaining, 10)
92+
return remainingCount <= threshold
93+
}
94+
95+
return false
96+
}
97+
98+
export const getRateLimitInfo = (headers: Record<string, string> | undefined) => {
99+
if (!headers) return null
100+
101+
const limit = getHeader(headers, 'ratelimit-limit')
102+
const remaining = getHeader(headers, 'ratelimit-remaining')
103+
const reset = getHeader(headers, 'ratelimit-reset')
104+
105+
return {
106+
limit: limit ? parseInt(limit, 10) : null,
107+
remaining: remaining ? parseInt(remaining, 10) : null,
108+
reset: reset ? parseInt(reset, 10) : null,
109+
resetDate: reset ? new Date(parseInt(reset, 10) * 1000) : null,
110+
}
111+
}

services/libs/integrations/src/integrations/gitlab/api/getForks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GitlabForkData } from '../types'
55
import { GitlabApiResult } from '../types'
66
import { RedisSemaphore } from '../utils/lock'
77

8+
import { handleGitlabError } from './errorHandler'
89
import { getUser } from './getUser'
910

1011
export const getForks = async ({
@@ -34,6 +35,8 @@ export const getForks = async ({
3435
forks = (await api.Projects.allForks(projectId, {
3536
updatedAfter: since,
3637
})) as ProjectSchema[]
38+
} catch (error) {
39+
throw handleGitlabError(error, `getForks:${projectId}`, ctx.log)
3740
} finally {
3841
await semaphore.release()
3942
}

services/libs/integrations/src/integrations/gitlab/api/getIssueDiscussions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IProcessStreamContext } from '../../../types'
44
import { GitlabApiResult, GitlabDisccusionCommentData } from '../types'
55
import { RedisSemaphore } from '../utils/lock'
66

7+
import { handleGitlabError } from './errorHandler'
78
import { getUser } from './getUser'
89

910
export const getIssueDiscussions = async ({
@@ -40,6 +41,8 @@ export const getIssueDiscussions = async ({
4041

4142
discussions = response.data as DiscussionSchema[]
4243
pagination = response.paginationInfo
44+
} catch (error) {
45+
throw handleGitlabError(error, `getIssueDiscussions:${projectId}:${issueIId}`, ctx.log)
4346
} finally {
4447
await semaphore.release()
4548
}

services/libs/integrations/src/integrations/gitlab/api/getIssues.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IProcessStreamContext } from '../../../types'
44
import { GitlabApiResult, GitlabIssueData } from '../types'
55
import { RedisSemaphore } from '../utils/lock'
66

7+
import { handleGitlabError } from './errorHandler'
78
import { getUser } from './getUser'
89

910
export const getIssues = async ({
@@ -33,6 +34,7 @@ export const getIssues = async ({
3334
let issues: IssueSchema[]
3435

3536
try {
37+
await semaphore.acquire()
3638
const response = await api.Issues.all({
3739
projectId,
3840
page,
@@ -43,6 +45,8 @@ export const getIssues = async ({
4345

4446
issues = response.data as IssueSchema[]
4547
pagination = response.paginationInfo
48+
} catch (error) {
49+
throw handleGitlabError(error, `getIssues:${projectId}`, ctx.log)
4650
} finally {
4751
await semaphore.release()
4852
}

services/libs/integrations/src/integrations/gitlab/api/getMergeRequestCommits.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { IProcessStreamContext } from '../../../types'
66
import { GitlabApiResult, GitlabMergeRequestCommitData } from '../types'
77
import { RedisSemaphore } from '../utils/lock'
88

9+
import { handleGitlabError } from './errorHandler'
10+
911
export const getMergeRequestCommits = async ({
1012
api,
1113
projectId,
@@ -31,6 +33,7 @@ export const getMergeRequestCommits = async ({
3133
const extendedCommits: ExpandedCommitSchema[] = []
3234

3335
try {
36+
await semaphore.acquire()
3437
const response = await api.MergeRequests.allCommits(projectId, mergeRequestIId, {
3538
page,
3639
perPage: 20,
@@ -47,11 +50,26 @@ export const getMergeRequestCommits = async ({
4750
extendedCommits.push(extendedCommit as ExpandedCommitSchema)
4851
await timeout(500)
4952
} catch (error) {
53+
const handledError = handleGitlabError(
54+
error,
55+
`getMergeRequestCommits:getCommit:${projectId}:${commit.id}`,
56+
ctx.log,
57+
)
58+
// Only rethrow if it's a rate limit error
59+
if (handledError.name === 'RateLimitError') {
60+
throw handledError
61+
}
5062
ctx.log.error(`Failed to fetch extended commit for ${commit.id}: ${error}`)
5163
}
5264
}
5365

5466
pagination = response.paginationInfo
67+
} catch (error) {
68+
throw handleGitlabError(
69+
error,
70+
`getMergeRequestCommits:${projectId}:${mergeRequestIId}`,
71+
ctx.log,
72+
)
5573
} finally {
5674
await semaphore.release()
5775
}

services/libs/integrations/src/integrations/gitlab/api/getMergeRequestDiscussions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IProcessStreamContext } from '../../../types'
44
import { GitlabApiResult, GitlabDisccusionCommentData } from '../types'
55
import { RedisSemaphore } from '../utils/lock'
66

7+
import { handleGitlabError } from './errorHandler'
78
import { getUser } from './getUser'
89

910
export const getMergeRequestDiscussions = async ({
@@ -33,6 +34,7 @@ export const getMergeRequestDiscussions = async ({
3334
let discussions: DiscussionSchema[]
3435

3536
try {
37+
await semaphore.acquire()
3638
const response = await api.MergeRequestDiscussions.all(projectId, mergeRequestIId, {
3739
showExpanded: true,
3840
page,
@@ -41,6 +43,12 @@ export const getMergeRequestDiscussions = async ({
4143

4244
discussions = response.data as DiscussionSchema[]
4345
pagination = response.paginationInfo
46+
} catch (error) {
47+
throw handleGitlabError(
48+
error,
49+
`getMergeRequestDiscussions:${projectId}:${mergeRequestIId}`,
50+
ctx.log,
51+
)
4452
} finally {
4553
await semaphore.release()
4654
}

services/libs/integrations/src/integrations/gitlab/api/getMergeRequestDiscussionsAndEvents.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IProcessStreamContext } from '../../../types'
44
import { GitlabApiResult, GitlabDisccusionCommentData } from '../types'
55
import { RedisSemaphore } from '../utils/lock'
66

7+
import { handleGitlabError } from './errorHandler'
78
import { getUser } from './getUser'
89

910
export const getMergeRequestDiscussionsAndEvents = async ({
@@ -41,6 +42,12 @@ export const getMergeRequestDiscussionsAndEvents = async ({
4142

4243
discussions = response.data as DiscussionSchema[]
4344
pagination = response.paginationInfo
45+
} catch (error) {
46+
throw handleGitlabError(
47+
error,
48+
`getMergeRequestDiscussionsAndEvents:${projectId}:${mergeRequestIId}`,
49+
ctx.log,
50+
)
4451
} finally {
4552
await semaphore.release()
4653
}

services/libs/integrations/src/integrations/gitlab/api/getMergeRequestEvents.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IProcessStreamContext } from '../../../types'
44
import { GitlabApiResult, GitlabDisccusionCommentData } from '../types'
55
import { RedisSemaphore } from '../utils/lock'
66

7+
import { handleGitlabError } from './errorHandler'
78
import { getUser } from './getUser'
89

910
export const getMergeRequestEvents = async ({
@@ -41,6 +42,8 @@ export const getMergeRequestEvents = async ({
4142

4243
discussions = response.data as DiscussionSchema[]
4344
pagination = response.paginationInfo
45+
} catch (error) {
46+
throw handleGitlabError(error, `getMergeRequestEvents:${projectId}:${mergeRequestIId}`, ctx.log)
4447
} finally {
4548
await semaphore.release()
4649
}

services/libs/integrations/src/integrations/gitlab/api/getMergeRequests.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IProcessStreamContext } from '../../../types'
44
import { GitlabApiResult, GitlabMergeRequestData } from '../types'
55
import { RedisSemaphore } from '../utils/lock'
66

7+
import { handleGitlabError } from './errorHandler'
78
import { getUser } from './getUser'
89

910
export const getMergeRequests = async ({
@@ -44,6 +45,8 @@ export const getMergeRequests = async ({
4445

4546
mergeRequests = response.data as MergeRequestSchema[]
4647
pagination = response.paginationInfo
48+
} catch (error) {
49+
throw handleGitlabError(error, `getMergeRequests:${projectId}`, ctx.log)
4750
} finally {
4851
await semaphore.release()
4952
}

services/libs/integrations/src/integrations/gitlab/api/getStars.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IProcessStreamContext } from '../../../types'
44
import { GitlabApiResult, GitlabStarData } from '../types'
55
import { RedisSemaphore } from '../utils/lock'
66

7+
import { handleGitlabError } from './errorHandler'
78
import { getUser } from './getUser'
89

910
export const getStars = async ({
@@ -28,6 +29,8 @@ export const getStars = async ({
2829
try {
2930
await semaphore.acquire()
3031
stars = (await api.Projects.allStarrers(projectId)) as ProjectStarrerSchema[]
32+
} catch (error) {
33+
throw handleGitlabError(error, `getStars:${projectId}`, ctx.log)
3134
} finally {
3235
await semaphore.release()
3336
}

0 commit comments

Comments
 (0)