-
Notifications
You must be signed in to change notification settings - Fork 731
Expand file tree
/
Copy pathpkgGoDevClient.ts
More file actions
105 lines (95 loc) · 3.55 KB
/
Copy pathpkgGoDevClient.ts
File metadata and controls
105 lines (95 loc) · 3.55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import { FetchError, GoStatusResult, isFetchError } from './types'
const BASE = process.env.PKGGODEV_BASE_URL ?? 'https://pkg.go.dev'
// 40 QPS per IP. Keep a single in-process gap under that ceiling.
const MIN_INTERVAL_MS = parseInt(process.env.PKGGODEV_MIN_INTERVAL_MS ?? '30', 10)
const MAX_429_RETRIES = 5
const MAX_PAGES = 20
interface VersionItem {
version: string
deprecated?: boolean
retracted?: boolean
latestVersion?: string
}
interface VersionsPage {
items?: VersionItem[]
total?: number
nextPageToken?: string
}
let lastRequestAt = 0
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
async function throttle(): Promise<void> {
const wait = lastRequestAt + MIN_INTERVAL_MS - Date.now()
if (wait > 0) await sleep(wait)
lastRequestAt = Date.now()
}
async function getPage(url: string, timeoutMs: number): Promise<VersionsPage | FetchError> {
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
await throttle()
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
let res: Response
try {
res = await fetch(url, { signal: controller.signal })
} catch (e) {
clearTimeout(timer)
return { kind: 'TRANSIENT', message: `network error: ${(e as Error).message}` }
}
clearTimeout(timer)
if (res.status === 429) {
const reset = parseInt(res.headers.get('x-ratelimit-reset') ?? '0', 10)
const waitMs = reset ? Math.max(1000, reset * 1000 - Date.now() + 500) : 2000
await sleep(waitMs)
continue
}
// Any other 4xx is permanent (e.g. 400 for submodule/non-module-root paths) — skip, don't retry.
if (res.status >= 400 && res.status < 500) {
return { kind: 'NOT_FOUND', statusCode: res.status, message: `${res.status}` }
}
if (res.status !== 200) {
return {
kind: 'TRANSIENT',
statusCode: res.status,
message: `unexpected status ${res.status}`,
}
}
try {
return (await res.json()) as VersionsPage
} catch {
return { kind: 'MALFORMED', message: 'invalid json' }
}
}
return { kind: 'RATE_LIMIT', statusCode: 429, message: '429 after retries' }
}
// Module status from /v1beta/versions: 'deprecated' if the latest version is
// deprecated/retracted, else 'active'. Pages are newest-first and every item carries
// latestVersion, so the match is normally on page 1; paginate (token query param,
// request otherwise verbatim) only if it isn't.
export async function fetchStatus(
module: string,
timeoutMs: number,
onHeartbeat?: () => void,
): Promise<GoStatusResult | FetchError> {
let token: string | undefined
let versionsCount: number | null = null
for (let page = 0; page < MAX_PAGES; page++) {
const url = `${BASE}/v1beta/versions/${module}${token ? `?token=${encodeURIComponent(token)}` : ''}`
const result = await getPage(url, timeoutMs)
onHeartbeat?.()
if (isFetchError(result)) return result
if (versionsCount === null && typeof result.total === 'number') versionsCount = result.total
const items = result.items ?? []
const latestVersion = items.find((i) => i.latestVersion)?.latestVersion
const match = latestVersion ? items.find((i) => i.version === latestVersion) : undefined
if (match) {
return {
status: match.deprecated || match.retracted ? 'deprecated' : 'active',
versionsCount,
}
}
if (!result.nextPageToken) break
token = result.nextPageToken
}
return { kind: 'NOT_FOUND', message: 'latest version entry not found' }
}