Skip to content

Commit e21646c

Browse files
authored
fix: fix patch repo rate limiting by switching to raw links + stream downloads to disk (#214)
- Switched from github/ gitlab api links to raw link which gives a much generous amount of calls before rate limiting. The latest stable/dev versions are now resolve from the repo's raw patches-bundle.json. (API is still present as fallback for fetching patches in case raw fails and also for getting older patches if a user opens the patch version selection screen.) - Patch downloads now stream straight to disk (as .part file) instead of buffering the whole file in memory. This fixes large files from failing. - Implemented a slimmed down version of the manager's HttpService. This unifies a lot of stuff that the GitHubPatchSource and GitLabPatchSource used to do, hence this does a decent bit of unification too. - Added progress for patch download (Minor UI change to make sure the users aren't kept in the dark. Won't notice it usually, but can be seen on larger patches)
1 parent f232267 commit e21646c

12 files changed

Lines changed: 514 additions & 159 deletions

File tree

src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package app.morphe.cli.command
77

88
import io.ktor.client.HttpClient
99
import io.ktor.client.engine.cio.CIO
10+
import io.ktor.client.plugins.HttpTimeout
1011
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
1112
import io.ktor.serialization.kotlinx.json.json
1213
import kotlinx.serialization.json.Json
@@ -23,6 +24,16 @@ object CliHttpClient {
2324
install(ContentNegotiation) {
2425
json(Json { ignoreUnknownKeys = true })
2526
}
27+
// Idle/socket timeouts (not a total cap) so large .mpp downloads don't fail
28+
// for being big. Only genuine stalls or issues fail. Default CIO requestTimeout is 15s.
29+
install(HttpTimeout) {
30+
connectTimeoutMillis = 30_000
31+
socketTimeoutMillis = 60_000
32+
}
33+
// Retry/429 handling lives in HttpService (single layer), not a client plugin.
34+
engine {
35+
requestTimeout = 0
36+
}
2637
}
2738
}
2839
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-cli
4+
*/
5+
6+
package app.morphe.engine.model
7+
8+
import kotlinx.serialization.SerialName
9+
import kotlinx.serialization.Serializable
10+
11+
/**
12+
* The `patches-bundle.json` a patch repo publishes at its root (branch `main` = stable,
13+
* `dev` = prerelease). Read via the **raw CDN** (`raw.githubusercontent.com` / GitLab
14+
* raw) so resolving the latest patch doesn't spend a call against the rate-limited
15+
* releases API.
16+
*
17+
* Mirrors morphe manager's `PatchesReleaseInfo`.
18+
*/
19+
@Serializable
20+
data class PatchesBundle(
21+
val version: String,
22+
@SerialName("download_url") val downloadUrl: String,
23+
@SerialName("created_at") val createdAt: String? = null,
24+
val description: String? = null,
25+
@SerialName("signature_download_url") val signatureDownloadUrl: String? = null,
26+
) {
27+
/** Adapt to the shared [Release] model so the rest of the pipeline is unchanged. */
28+
fun toRelease(prerelease: Boolean): Release {
29+
val assetName = downloadUrl.substringAfterLast('/').ifBlank { "patches-$version.mpp" }
30+
return Release(
31+
tagName = if (version.startsWith("v", ignoreCase = true)) version else "v$version",
32+
isPrerelease = prerelease,
33+
createdAt = createdAt,
34+
body = description,
35+
assets = listOf(ReleaseAsset(name = assetName, downloadUrl = downloadUrl)),
36+
)
37+
}
38+
}
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-cli
4+
*/
5+
6+
package app.morphe.engine.network
7+
8+
import io.ktor.client.HttpClient
9+
import io.ktor.client.request.HttpRequestBuilder
10+
import io.ktor.client.request.get
11+
import io.ktor.client.request.head
12+
import io.ktor.client.request.prepareGet
13+
import io.ktor.client.statement.HttpResponse
14+
import io.ktor.client.statement.bodyAsChannel
15+
import io.ktor.client.statement.bodyAsText
16+
import io.ktor.http.HttpHeaders
17+
import io.ktor.http.HttpStatusCode
18+
import io.ktor.http.isSuccess
19+
import io.ktor.utils.io.jvm.javaio.toInputStream
20+
import kotlinx.coroutines.CancellationException
21+
import kotlinx.coroutines.Dispatchers
22+
import kotlinx.coroutines.delay
23+
import kotlinx.coroutines.withContext
24+
import kotlinx.serialization.decodeFromString
25+
import kotlinx.serialization.json.Json
26+
import java.io.File
27+
import java.io.FileOutputStream
28+
import java.io.InputStream
29+
import java.io.OutputStream
30+
import java.util.logging.Logger
31+
32+
/**
33+
* Central HTTP layer for the desktop/JVM engine, wrapping a shared Ktor
34+
* [HttpClient]. Both [GitHubPatchSource][app.morphe.engine.patches.GitHubPatchSource]
35+
* and [GitLabPatchSource][app.morphe.engine.patches.GitLabPatchSource] go
36+
* through this instead of each hand-rolling request/stream/retry logic.
37+
*
38+
* Ported (slimmed) from morphe-manager's `HttpService`. The multipart
39+
* range-parallel downloader has been intentionally dropped since it avoid the concurrency/range
40+
* complexity (Can add this if required). Timeouts + engine live on the injected client (CIO).
41+
*
42+
* Responsibilities:
43+
* - [request]: GET + ContentNegotiation decode to [T]
44+
* - [getText]: GET raw body text (providers that hand-parse JSON, e.g. GitLab,
45+
* and raw `patches-bundle.json` which is served as text/plain)
46+
* - [head]: HEAD probe (GitLab's cosmetic asset-size resolution)
47+
* - [downloadToFile]: stream a response straight to disk (never buffered
48+
* in memory), via a `.part` file with atomic swap + cleanup on failure
49+
* - retry on transient failures + HTTP 429 (honoring `Retry-After`)
50+
*/
51+
class HttpService(
52+
@PublishedApi internal val http: HttpClient,
53+
@PublishedApi internal val json: Json = Json { ignoreUnknownKeys = true },
54+
) {
55+
@PublishedApi
56+
internal val logger: Logger = Logger.getLogger(HttpService::class.java.name)
57+
58+
/**
59+
* GET [url] and decode the body to [T].
60+
*
61+
* Decoding is done manually (`json.decodeFromString`) off the raw text,
62+
* independent of the response's `Content-Type`. So a raw `patches-bundle.json`
63+
* served as `text/plain` decodes exactly like an `application/json` API
64+
* response, no ContentNegotiation needed. `T == String` returns the raw
65+
* body text unparsed (use this for providers that hand-roll JSON, e.g.
66+
* GitLab's release list).
67+
*
68+
* Throws [HttpException] on a non-2xx status after retries are exhausted.
69+
*/
70+
suspend inline fun <reified T> request(
71+
url: String,
72+
crossinline builder: HttpRequestBuilder.() -> Unit = {},
73+
): T = withRetry("request $url") {
74+
val response: HttpResponse = http.get(url) { builder() }
75+
response.throwIfError(url)
76+
val body = response.bodyAsText()
77+
if (T::class == String::class) {
78+
@Suppress("UNCHECKED_CAST")
79+
body as T
80+
} else {
81+
json.decodeFromString(body)
82+
}
83+
}
84+
85+
/**
86+
* HEAD [url] and return the raw [HttpResponse] (headers only). Callers
87+
* inspect status/headers themselves. Retries 429. Other statuses are
88+
* returned as is (GitLab treats any failure as "unknown size").
89+
*/
90+
suspend fun head(
91+
url: String,
92+
builder: HttpRequestBuilder.() -> Unit = {},
93+
): HttpResponse = withRetry("head $url") {
94+
val response = http.head(url) { builder() }
95+
if (response.status == HttpStatusCode.TooManyRequests) {
96+
throw TooManyRequestsException(response.retryAfterMillis())
97+
}
98+
response
99+
}
100+
101+
/**
102+
* Download [url] to [saveLocation], streaming straight to disk. The
103+
* response body is never stored in memory (Pratham's chess patch is 100MB+ and that would
104+
* cause issues. This fixes it. Writes to a `.part` file and atomically swaps on success;
105+
* removes the partial file on any failure. Throws on failure after retries.
106+
*/
107+
suspend fun downloadToFile(
108+
url: String,
109+
saveLocation: File,
110+
onProgress: ((bytesRead: Long, contentLength: Long?) -> Unit)? = null,
111+
builder: HttpRequestBuilder.() -> Unit = {},
112+
): File {
113+
saveLocation.parentFile?.mkdirs()
114+
val part = File(saveLocation.parentFile, "${saveLocation.name}.part")
115+
try {
116+
withRetry("download ${saveLocation.name}") {
117+
// append=false: each (re)attempt truncates, so restarting is safe.
118+
FileOutputStream(part, false).use { out ->
119+
http.prepareGet(url) { builder() }.execute { response ->
120+
response.throwIfError(url)
121+
val contentLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
122+
withContext(Dispatchers.IO) {
123+
response.bodyAsChannel().toInputStream().use { input ->
124+
copyStreaming(input, out, contentLength, onProgress)
125+
}
126+
}
127+
}
128+
}
129+
}
130+
if (part.length() == 0L) throw HttpException(null, url, "download returned 0 bytes")
131+
if (saveLocation.exists()) saveLocation.delete()
132+
if (!part.renameTo(saveLocation)) {
133+
part.copyTo(saveLocation, overwrite = true); part.delete()
134+
}
135+
return saveLocation
136+
} catch (t: Throwable) {
137+
runCatching { if (part.exists()) part.delete() }
138+
throw t
139+
}
140+
}
141+
142+
/**
143+
* Copy [input] → [output] in 64 KB chunks (constant memory), reporting byte
144+
* progress. Progress is throttled: at most once per [PROGRESS_MIN_BYTES] or
145+
* [PROGRESS_INTERVAL_MS], whichever first, plus a guaranteed final call.
146+
*/
147+
private fun copyStreaming(
148+
input: InputStream,
149+
output: OutputStream,
150+
contentLength: Long?,
151+
onProgress: ((bytesRead: Long, contentLength: Long?) -> Unit)?,
152+
) {
153+
val buffer = ByteArray(64 * 1024)
154+
var total = 0L
155+
var lastBytes = 0L
156+
var lastAt = 0L
157+
while (true) {
158+
val read = input.read(buffer)
159+
if (read == -1) break
160+
output.write(buffer, 0, read)
161+
total += read
162+
if (onProgress != null) {
163+
val now = System.currentTimeMillis()
164+
if (total - lastBytes >= PROGRESS_MIN_BYTES || now - lastAt >= PROGRESS_INTERVAL_MS) {
165+
lastBytes = total
166+
lastAt = now
167+
onProgress(total, contentLength)
168+
}
169+
}
170+
}
171+
onProgress?.invoke(total, contentLength)
172+
}
173+
174+
/**
175+
* Single retry layer: retries [block] on transient exceptions (connection
176+
* reset, DNS, timeout, 5xx) and HTTP 429 (honoring `Retry-After`), with
177+
* exponential backoff. Permanent 4xx (404/401/403…) surface immediately;
178+
* cancellation is never caught.
179+
*/
180+
@PublishedApi
181+
internal suspend fun <T> withRetry(operation: String, block: suspend () -> T): T {
182+
var attempt = 0
183+
var delayMs = INITIAL_RETRY_DELAY_MS
184+
while (true) {
185+
attempt++
186+
try {
187+
return block()
188+
} catch (t: CancellationException) {
189+
throw t
190+
} catch (t: TooManyRequestsException) {
191+
if (attempt >= MAX_RETRY_ATTEMPTS) {
192+
throw HttpException(HttpStatusCode.TooManyRequests, operation, cause = t)
193+
}
194+
val wait = (t.retryAfterMillis ?: delayMs).coerceAtMost(MAX_RETRY_DELAY_MS)
195+
logger.warning("$operation hit 429 (attempt $attempt/$MAX_RETRY_ATTEMPTS), waiting ${wait}ms")
196+
delay(wait)
197+
delayMs = (delayMs * 2).coerceAtMost(MAX_RETRY_DELAY_MS)
198+
} catch (t: HttpException) {
199+
if (!t.isRetryable || attempt >= MAX_RETRY_ATTEMPTS) throw t
200+
logger.warning("$operation attempt $attempt: retryable HTTP error: ${t.message}")
201+
delay(delayMs)
202+
delayMs = (delayMs * 2).coerceAtMost(MAX_RETRY_DELAY_MS)
203+
} catch (t: Exception) {
204+
if (attempt >= MAX_RETRY_ATTEMPTS) throw t
205+
logger.warning("$operation attempt $attempt failed: ${t::class.simpleName}: ${t.message}")
206+
delay(delayMs)
207+
delayMs = (delayMs * 2).coerceAtMost(MAX_RETRY_DELAY_MS)
208+
}
209+
}
210+
}
211+
212+
/** Throw on 429 (→ retry with Retry-After) or any other non-2xx (→ [HttpException]). */
213+
@PublishedApi
214+
internal suspend fun HttpResponse.throwIfError(url: String) {
215+
if (status == HttpStatusCode.TooManyRequests) throw TooManyRequestsException(retryAfterMillis())
216+
if (!status.isSuccess()) {
217+
val body = runCatching { bodyAsText() }.getOrNull()
218+
throw HttpException(status, url, body)
219+
}
220+
}
221+
222+
/** `Retry-After` (seconds) → millis, or null when absent/unparseable. */
223+
@PublishedApi
224+
internal fun HttpResponse.retryAfterMillis(): Long? =
225+
headers[HttpHeaders.RetryAfter]?.toLongOrNull()?.coerceAtLeast(0)?.times(1000)
226+
227+
/**
228+
* Preserves the HTTP status (when known), the request URL, and a short
229+
* body snippet so callers get real diagnostics. [isRetryable] drives
230+
* [withRetry]: timeouts (null status), 5xx, and 429 are transient. Other 4xx are not.
231+
*/
232+
class HttpException(
233+
val status: HttpStatusCode?,
234+
val requestUrl: String? = null,
235+
val responseBodySnippet: String? = null,
236+
cause: Throwable? = null,
237+
) : Exception(
238+
buildString {
239+
append("HTTP request failed")
240+
if (status != null) append(" with status $status")
241+
if (requestUrl != null) append(" for $requestUrl")
242+
if (responseBodySnippet != null) append(": ${responseBodySnippet.take(200)}")
243+
},
244+
cause,
245+
) {
246+
val isRetryable: Boolean
247+
get() = status == null || status == HttpStatusCode.TooManyRequests || status.value >= 500
248+
}
249+
250+
class TooManyRequestsException(val retryAfterMillis: Long?) :
251+
Exception("HTTP 429 Too Many Requests")
252+
253+
companion object {
254+
private const val MAX_RETRY_ATTEMPTS = 3
255+
private const val INITIAL_RETRY_DELAY_MS = 1_000L
256+
private const val MAX_RETRY_DELAY_MS = 30_000L
257+
258+
/** Min bytes between download-progress callbacks. */
259+
private const val PROGRESS_MIN_BYTES = 64 * 1024L
260+
261+
/** Min ms between download-progress callbacks. */
262+
private const val PROGRESS_INTERVAL_MS = 200L
263+
}
264+
}

0 commit comments

Comments
 (0)