Skip to content

Commit e1ebe2c

Browse files
committed
LinkChecker: Support several types of rate limiting
1 parent 49e11a9 commit e1ebe2c

File tree

3 files changed

+55
-16
lines changed

3 files changed

+55
-16
lines changed

src/main/kotlin/name/valery1707/problem/LinkChecker.kt

+38-14
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package name.valery1707.problem
22

33
import java.net.URI
44
import java.net.http.HttpClient
5+
import java.net.http.HttpHeaders
56
import java.net.http.HttpRequest
67
import java.net.http.HttpResponse
78
import java.nio.file.Path
89
import java.time.Duration
910
import java.time.Instant
11+
import java.time.format.DateTimeFormatter
1012
import java.time.temporal.ChronoField.NANO_OF_SECOND
1113
import kotlin.io.path.ExperimentalPathApi
1214
import kotlin.io.path.PathWalkOption
@@ -115,19 +117,7 @@ class LinkChecker(private val root: Path) {
115117
//Rate limiting: wait and retry
116118
in HTTP_RATE_LIMIT -> {
117119
val now = Instant.now()
118-
val await = response.headers()
119-
120-
// todo Extract to method
121-
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#checking-your-rate-limit-status
122-
.map()["x-ratelimit-reset"]
123-
?.asSequence()
124-
?.map(String::toLong)?.map(Instant::ofEpochSecond)
125-
?.map { Duration.between(now.with(NANO_OF_SECOND, 0), it) }
126-
?.map(Duration::toMillis)
127-
?.filter { it >= 0 }
128-
?.firstOrNull()
129-
130-
?: 500
120+
val await = response.headers().rateLimitAwait(now) ?: 500
131121

132122
logger.debug("Await: $await ms")
133123
Thread.sleep(await)
@@ -143,6 +133,40 @@ class LinkChecker(private val root: Path) {
143133
}
144134

145135
private val HTTP_REDIRECT = setOf(301, 302, 307, 308)
146-
private val HTTP_RATE_LIMIT = setOf(403)
136+
private val HTTP_RATE_LIMIT = setOf(403, 429)
137+
138+
private fun HttpHeaders.rateLimitAwait(now: Instant): Long? {
139+
val map = map()
140+
return HTTP_RATE_LIMIT_EXTRACTORS
141+
.flatMap { map[it.key]?.asSequence()?.map { v -> it.value(v.trim(), now) } ?: emptySequence() }
142+
.filterNotNull()
143+
.firstOrNull { it >= 0 }
144+
}
145+
146+
private val HTTP_RATE_LIMIT_EXTRACTORS: Map<String, (String, Instant) -> Long?> = mapOf(
147+
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#checking-your-rate-limit-status
148+
"x-ratelimit-reset" to { value, now ->
149+
value
150+
.toLong()
151+
.let(Instant::ofEpochSecond)
152+
.let { Duration.between(now.with(NANO_OF_SECOND, 0), it) }
153+
.let(Duration::toMillis)
154+
},
155+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
156+
"Retry-After" to { value, now ->
157+
if (value.isDigit()) value.toLong()
158+
else HTTP_DATE_FORMAT
159+
.parse(value, Instant::from)
160+
.let { Duration.between(now.with(NANO_OF_SECOND, 0), it) }
161+
.let(Duration::toMillis)
162+
},
163+
)
164+
165+
/**
166+
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date">Specification</a>
167+
*/
168+
internal val HTTP_DATE_FORMAT = DateTimeFormatter.RFC_1123_DATE_TIME
169+
170+
private fun String.isDigit(): Boolean = this.all { it.isDigit() }
147171
}
148172
}

src/test/kotlin/name/valery1707/problem/LinkCheckerTest.kt

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package name.valery1707.problem
22

3+
import name.valery1707.problem.LinkChecker.Companion.HTTP_DATE_FORMAT
34
import name.valery1707.problem.LinkChecker.Companion.toURI
45
import org.assertj.core.api.Assertions.assertThat
56
import org.assertj.core.api.Assertions.fail
@@ -19,6 +20,7 @@ import java.net.http.HttpResponse
1920
import java.nio.file.Path
2021
import java.time.Duration
2122
import java.time.Instant
23+
import java.time.ZoneId
2224
import java.util.*
2325
import java.util.concurrent.CompletableFuture
2426
import java.util.concurrent.Executor
@@ -61,6 +63,10 @@ internal class LinkCheckerTest {
6163
fun notFound(): ResponseMeta = { 404 to mapOf() }
6264
fun redirect(code: Int, target: String): ResponseMeta = { code to mapOf("Location" to target) }
6365
fun rateLimitGH(awaitMillis: Long): ResponseMeta = { 403 to mapOf("x-ratelimit-reset" to Instant.now().plusMillis(awaitMillis).epochSecond.toString()) }
66+
fun rateLimitSpecSec(awaitSec: Int): ResponseMeta = { 429 to mapOf("Retry-After" to awaitSec.toString()) }
67+
fun rateLimitSpecDate(awaitMillis: Long): ResponseMeta = {
68+
429 to mapOf("Retry-After" to HTTP_DATE_FORMAT.format(Instant.now().plusMillis(awaitMillis).atZone(ZoneId.systemDefault())))
69+
}
6470

6571
//Check links via: curl --silent -X GET --head 'URL'
6672
val client = MockedHttpClient.fromMeta(
@@ -83,6 +89,13 @@ internal class LinkCheckerTest {
8389
rateLimitGH(-1500),
8490
ok(),
8591
),
92+
"https://www.bearer.com/" to listOf(
93+
// Use variant with "delay-seconds"
94+
rateLimitSpecSec(1),
95+
// Use variant with "http-date"
96+
rateLimitSpecDate(100),
97+
ok(),
98+
),
8699
"https://github.com/androidx/androidx/blob/androidx-main/buildSrc/public/src/main/kotlin/androidx/build/LibraryGroups.kt" to listOf(
87100
notFound(),
88101
),
@@ -95,7 +108,7 @@ internal class LinkCheckerTest {
95108
mapOf(
96109
"Demo.md:1:25" to "https://ya.ru -> 302 -> https://ya.ru/",
97110
"Demo.md:3:14" to "http://schema.org -> 301 -> https://schema.org/",
98-
"Demo.md:5:14" to "https://github.com/androidx/androidx/blob/androidx-main/buildSrc/public/src/main/kotlin/androidx/build/LibraryGroups.kt -> 404",
111+
"Demo.md:7:14" to "https://github.com/androidx/androidx/blob/androidx-main/buildSrc/public/src/main/kotlin/androidx/build/LibraryGroups.kt -> 404",
99112
),
100113
)
101114
}
+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
Link with name: [named](https://ya.ru).
22
Link with name: [named](https://ya.ru/).
33
Link inlined http://schema.org.
4-
Link with rate limiting: https://github.com/androidx/androidx/blob/androidx-main/build.gradle
4+
Link with rate limiting:
5+
* https://github.com/androidx/androidx/blob/androidx-main/build.gradle
6+
* https://www.bearer.com/
57
Link absent: https://github.com/androidx/androidx/blob/androidx-main/buildSrc/public/src/main/kotlin/androidx/build/LibraryGroups.kt

0 commit comments

Comments
 (0)