Skip to content

Commit fded16b

Browse files
authored
github and cf: improve detection and logging of rate limiting (#401)
1 parent cad305b commit fded16b

File tree

8 files changed

+216
-60
lines changed

8 files changed

+216
-60
lines changed

src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import me.itzg.helpers.curseforge.model.ModLoader;
4141
import me.itzg.helpers.errors.GenericException;
4242
import me.itzg.helpers.errors.InvalidParameterException;
43+
import me.itzg.helpers.errors.RateLimitException;
4344
import me.itzg.helpers.fabric.FabricLauncherInstaller;
4445
import me.itzg.helpers.files.Manifests;
4546
import me.itzg.helpers.files.ResultsFileWriter;
@@ -70,10 +71,12 @@ public class CurseForgeInstaller {
7071
private final Path outputDir;
7172
private final Path resultsFile;
7273

73-
@Getter @Setter
74+
@Getter
75+
@Setter
7476
private String apiBaseUrl = "https://api.curseforge.com";
7577

76-
@Getter @Setter
78+
@Getter
79+
@Setter
7780
private String apiKey;
7881

7982
@Getter
@@ -157,7 +160,7 @@ public void installFromModpackManifest(String modpackManifestLoc, String slug) t
157160

158161
processModpackManifest(context, modpackManifest,
159162
() -> new Result(Collections.emptyList(), null)
160-
);
163+
);
161164
});
162165
}
163166

@@ -183,14 +186,15 @@ void install(String slug, InstallationEntryPoint entryPoint) throws IOException
183186
if (apiKey == null || apiKey.isEmpty()) {
184187
if (manifest != null) {
185188
log.warn("API key is not set, so will re-use previous modpack installation of {}",
186-
manifest.getSlug() != null ? manifest.getSlug() : "Project ID "+manifest.getModId());
189+
manifest.getSlug() != null ? manifest.getSlug() : "Project ID " + manifest.getModId()
190+
);
187191
log.warn("Obtain an API key from " + ETERNAL_DEVELOPER_CONSOLE_URL
188-
+ " and set the environment variable "+ API_KEY_VAR +" in order to restore full functionality.");
192+
+ " and set the environment variable " + API_KEY_VAR + " in order to restore full functionality.");
189193
return;
190194
}
191195
else {
192196
throw new InvalidParameterException("API key is not set. Obtain an API key from " + ETERNAL_DEVELOPER_CONSOLE_URL
193-
+ " and set the environment variable "+ API_KEY_VAR);
197+
+ " and set the environment variable " + API_KEY_VAR);
194198
}
195199
}
196200

@@ -209,11 +213,19 @@ void install(String slug, InstallationEntryPoint entryPoint) throws IOException
209213

210214
} catch (FailedRequestException e) {
211215
if (e.getStatusCode() == 403) {
212-
throw new InvalidParameterException(String.format("Access to %s is forbidden or rate-limit has been exceeded."
213-
+ " Ensure %s is set to a valid API key from %s or allow rate-limit to reset.",
216+
log.debug("Failed request details: {}", e.toString());
217+
218+
if (e.getBody().contains("There might be too much traffic")) {
219+
throw new RateLimitException(null, String.format("Access to %s has been rate-limited.", apiBaseUrl), e);
220+
}
221+
else {
222+
throw new InvalidParameterException(String.format("Access to %s is forbidden or rate-limit has been exceeded."
223+
+ " Ensure %s is set to a valid API key from %s or allow rate-limit to reset.",
214224
apiBaseUrl, API_KEY_VAR, ETERNAL_DEVELOPER_CONSOLE_URL
215-
), e);
216-
} else {
225+
), e);
226+
}
227+
}
228+
else {
217229
throw e;
218230
}
219231
}
@@ -229,13 +241,14 @@ static class InstallContext {
229241
}
230242

231243
interface InstallationEntryPoint {
244+
232245
void install(InstallContext context) throws IOException;
233246
}
234247

235248
private void installByRetrievingModpackZip(InstallContext context, String fileMatcher, Integer fileId) throws IOException {
236249
final CurseForgeMod curseForgeMod = context.cfApi.searchMod(context.slug,
237-
context.categoryInfo.getClassIdForSlug(CurseForgeApiClient.CATEGORY_MODPACKS)
238-
)
250+
context.categoryInfo.getClassIdForSlug(CurseForgeApiClient.CATEGORY_MODPACKS)
251+
)
239252
.block();
240253

241254
resolveModpackFileAndProcess(context, curseForgeMod, fileId, fileMatcher);
@@ -273,12 +286,14 @@ else if (Manifests.allFilesPresent(outputDir, context.prevInstallManifest, ignor
273286
}
274287

275288
log.info("Installing modpack '{}' version {} from provided modpack zip",
276-
modpackName, modpackVersion);
289+
modpackName, modpackVersion
290+
);
277291

278292
final ModPackResults results = processModpack(context, modpackManifest, overridesApplier);
279293

280294
finalizeResults(context, results,
281-
pseudoModId, pseudoFileId, results.getName());
295+
pseudoModId, pseudoFileId, results.getName()
296+
);
282297
}
283298

284299
private static boolean matchesPreviousInstall(InstallContext context, int modId, int fileId) {
@@ -362,7 +377,8 @@ else if (Manifests.allFilesPresent(outputDir, context.prevInstallManifest, ignor
362377
}
363378

364379
log.info("Processing modpack '{}' ({}) @ {}:{}", modFile.getDisplayName(),
365-
mod.getSlug(), modFile.getModId(), modFile.getId());
380+
mod.getSlug(), modFile.getModId(), modFile.getId()
381+
);
366382

367383
if (modpackZip == null) {
368384
throw new GenericException("Download of modpack zip was empty");
@@ -448,7 +464,8 @@ private void finalizeExistingInstallation(CurseForgeManifest prevManifest) throw
448464
/**
449465
* @throws MissingModsException if any mods need to be manually downloaded
450466
*/
451-
private void finalizeResults(InstallContext context, ModPackResults results, int modId, int fileId, String displayName) throws IOException {
467+
private void finalizeResults(InstallContext context, ModPackResults results, int modId, int fileId, String displayName)
468+
throws IOException {
452469
final CurseForgeManifest newManifest = CurseForgeManifest.builder()
453470
.modpackName(results.getName())
454471
.modpackVersion(results.getVersion())
@@ -535,19 +552,19 @@ private ModPackResults processModpack(InstallContext context,
535552

536553
log.debug("Evaluating projectId={} with exclude={} and forceInclude={}",
537554
projectID, exclude, forceInclude
538-
);
555+
);
539556

540557
return Mono.just(forceInclude || !exclude)
541558
.flatMap(proceed -> proceed ? Mono.just(true)
542559
: context.cfApi.getModInfo(projectID)
543560
.map(mod -> {
544561
log.info("Excluding mod file '{}' ({}) due to configuration",
545562
mod.getName(), mod.getSlug()
546-
);
563+
);
547564
// and filter away
548565
return false;
549566
})
550-
);
567+
);
551568
})
552569
// ...download and possibly unzip world file
553570
.flatMap(fileRef ->
@@ -567,7 +584,9 @@ private ModPackResults processModpack(InstallContext context,
567584
return buildResults(modpackManifest, modLoader, modFiles, overridesResult);
568585
}
569586

570-
private ModPackResults buildResults(MinecraftModpackManifest modpackManifest, ModLoader modLoader, List<PathWithInfo> modFiles, Result overridesResult) {
587+
private ModPackResults buildResults(MinecraftModpackManifest modpackManifest, ModLoader modLoader,
588+
List<PathWithInfo> modFiles, Result overridesResult
589+
) {
571590
return new ModPackResults()
572591
.setName(modpackManifest.getName())
573592
.setVersion(modpackManifest.getVersion())
@@ -722,22 +741,23 @@ else if (category.getSlug().equals("worlds")) {
722741
.setDownloadNeeded(resolveResult.downloadNeeded)
723742
.setModInfo(modInfo)
724743
.setCurseForgeFile(cfFile)
725-
: extractWorldZip(modInfo, resolveResult.path, outputPaths.getWorldsDir())
744+
: extractWorldZip(modInfo, resolveResult.path, outputPaths.getWorldsDir())
726745
)
727746
: resolvedFileMono
728-
.map(resolveResult ->
729-
new PathWithInfo(resolveResult.path)
730-
.setDownloadNeeded(resolveResult.downloadNeeded)
731-
.setModInfo(modInfo)
732-
.setCurseForgeFile(cfFile)
733-
);
747+
.map(resolveResult ->
748+
new PathWithInfo(resolveResult.path)
749+
.setDownloadNeeded(resolveResult.downloadNeeded)
750+
.setModInfo(modInfo)
751+
.setCurseForgeFile(cfFile)
752+
);
734753
});
735754
})
736755
.checkpoint(String.format("Downloading file from modpack %d:%d", projectID, fileID));
737756
}
738757

739758
@RequiredArgsConstructor
740759
static class ResolveResult {
760+
741761
final Path path;
742762
@Setter
743763
boolean downloadNeeded;
@@ -815,7 +835,7 @@ private PathWithInfo extractWorldZip(CurseForgeMod modInfo, Path zipPath, Path w
815835
ZipEntry nextEntry = zipInputStream.getNextEntry();
816836

817837
if (nextEntry == null || !nextEntry.isDirectory()) {
818-
throw new GenericException("Expected top-level directory in world zip "+zipPath);
838+
throw new GenericException("Expected top-level directory in world zip " + zipPath);
819839
}
820840

821841
// Will replace top diretory with slug name
Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package me.itzg.helpers.errors;
22

3+
import java.time.Instant;
34
import lombok.extern.slf4j.Slf4j;
45
import me.itzg.helpers.McImageHelper;
6+
import me.itzg.helpers.http.FailedRequestException;
57
import picocli.CommandLine;
68
import picocli.CommandLine.ExitCode;
79
import picocli.CommandLine.IExecutionExceptionHandler;
@@ -11,29 +13,46 @@
1113
@Slf4j
1214
public class ExceptionHandler implements IExecutionExceptionHandler {
1315

14-
private final McImageHelper mcImageHelper;
16+
private final McImageHelper mcImageHelper;
1517

16-
public ExceptionHandler(McImageHelper mcImageHelper) {
17-
this.mcImageHelper = mcImageHelper;
18-
}
18+
public ExceptionHandler(McImageHelper mcImageHelper) {
19+
this.mcImageHelper = mcImageHelper;
20+
}
21+
22+
@Override
23+
public int handleExecutionException(Exception e, CommandLine commandLine, ParseResult parseResult) {
1924

20-
@Override
21-
public int handleExecutionException(Exception e, CommandLine commandLine,
22-
ParseResult parseResult) {
25+
if (!mcImageHelper.isSilent()) {
26+
if (e instanceof InvalidParameterException) {
27+
log.error("Invalid parameter provided for '{}' command: {}", commandLine.getCommandName(), e.getMessage());
28+
log.debug("Invalid parameter details", e);
29+
}
30+
else if (e instanceof FailedRequestException) {
31+
logUnexpectedException(e, commandLine);
32+
log.debug("Failed request details: {}", e.toString());
33+
}
34+
else if (e instanceof RateLimitException) {
35+
logUnexpectedException(e, commandLine);
36+
final RateLimitException rle = (RateLimitException) e;
37+
final Instant delayUntil = rle.getDelayUntil();
38+
if (delayUntil != null) {
39+
log.warn("Rate limit response recommends waiting until {}", delayUntil);
40+
}
41+
}
42+
else {
43+
logUnexpectedException(e, commandLine);
44+
}
45+
}
2346

24-
if (!mcImageHelper.isSilent()) {
25-
if (e instanceof InvalidParameterException) {
26-
log.error("Invalid parameter provided for '{}' command: {}", commandLine.getCommandName(), e.getMessage());
27-
log.debug("Invalid parameter details", e);
28-
} else {
47+
final IExitCodeExceptionMapper mapper = commandLine.getExitCodeExceptionMapper();
48+
return mapper != null ? mapper.getExitCode(e) : ExitCode.SOFTWARE;
49+
}
50+
51+
private static void logUnexpectedException(Exception e, CommandLine commandLine) {
2952
log.error("'{}' command failed. Version is {}",
3053
commandLine.getCommandName(),
3154
McImageHelper.getVersion(),
32-
e);
33-
}
55+
e
56+
);
3457
}
35-
36-
final IExitCodeExceptionMapper mapper = commandLine.getExitCodeExceptionMapper();
37-
return mapper != null ? mapper.getExitCode(e) : ExitCode.SOFTWARE;
38-
}
3958
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package me.itzg.helpers.errors;
2+
3+
import java.time.Instant;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public class RateLimitException extends RuntimeException {
8+
9+
private final Instant delayUntil;
10+
11+
public RateLimitException(Instant delayUntil, String message, Throwable cause) {
12+
super(message, cause);
13+
this.delayUntil = delayUntil;
14+
}
15+
}

src/main/java/me/itzg/helpers/github/GithubClient.java

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
package me.itzg.helpers.github;
22

3+
import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN;
4+
import static io.netty.handler.codec.http.HttpResponseStatus.TOO_MANY_REQUESTS;
5+
6+
import io.netty.handler.codec.http.HttpHeaders;
7+
import io.netty.handler.codec.http.HttpResponseStatus;
38
import java.net.URI;
49
import java.nio.file.Path;
10+
import java.time.Instant;
511
import java.util.Collections;
612
import java.util.regex.Pattern;
713
import java.util.stream.Collectors;
814
import lombok.extern.slf4j.Slf4j;
15+
import me.itzg.helpers.errors.RateLimitException;
916
import me.itzg.helpers.github.model.Asset;
1017
import me.itzg.helpers.github.model.Release;
1118
import me.itzg.helpers.http.FailedRequestException;
@@ -34,9 +41,25 @@ public Mono<Path> downloadLatestAsset(String org, String repo, @Nullable Pattern
3441
.acceptContentTypes(Collections.singletonList("application/vnd.github+json"))
3542
.toObject(Release.class)
3643
.assemble()
37-
.onErrorResume(throwable -> throwable instanceof FailedRequestException && ((FailedRequestException) throwable).getStatusCode() == 404,
38-
throwable -> Mono.empty()
39-
)
44+
.onErrorResume(throwable -> {
45+
if (throwable instanceof FailedRequestException) {
46+
final FailedRequestException fre = (FailedRequestException) throwable;
47+
if (fre.getStatusCode() == HttpResponseStatus.NOT_FOUND.code()) {
48+
return Mono.empty();
49+
}
50+
51+
if ((fre.getStatusCode() == FORBIDDEN.code() || fre.getStatusCode() == TOO_MANY_REQUESTS.code())) {
52+
final HttpHeaders headers = fre.getHeaders();
53+
final String resetTimeStr = headers.get("x-ratelimit-reset");
54+
if (resetTimeStr != null) {
55+
return Mono.error(new RateLimitException(Instant.ofEpochSecond(Long.parseLong(resetTimeStr)),
56+
"Rate-limit exceeded", fre
57+
));
58+
}
59+
}
60+
}
61+
return Mono.error(throwable);
62+
})
4063
.flatMap(release -> {
4164
if (log.isDebugEnabled()) {
4265
log.debug("Assets in latest release '{}': {}",

src/main/java/me/itzg/helpers/http/FailedRequestException.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package me.itzg.helpers.http;
22

3+
import io.netty.handler.codec.http.HttpHeaders;
34
import io.netty.handler.codec.http.HttpResponseStatus;
45
import java.net.URI;
56
import lombok.Getter;
@@ -11,31 +12,34 @@ public class FailedRequestException extends RuntimeException {
1112
private final URI uri;
1213
private final int statusCode;
1314
private final String body;
15+
private final HttpHeaders headers;
1416

1517
/**
1618
* Reactor Netty flavor
1719
*/
18-
public FailedRequestException(HttpResponseStatus status, URI uri, String body, String msg) {
20+
public FailedRequestException(HttpResponseStatus status, URI uri, String body, String msg, HttpHeaders headers) {
1921
super(
2022
String.format("HTTP request of %s failed with %s: %s", uri, status, msg)
2123
);
2224
this.uri = uri;
2325
this.statusCode = status.code();
2426
this.body = body;
27+
this.headers = headers;
2528
}
2629

2730
@SuppressWarnings("unused")
2831
public static boolean isNotFound(Throwable throwable) {
2932
return isStatus(throwable, HttpResponseStatus.NOT_FOUND);
3033
}
3134

32-
public static boolean isBadRequest(Throwable throwable) {
33-
return isStatus(throwable, HttpResponseStatus.BAD_REQUEST);
34-
}
35-
36-
private static boolean isStatus(Throwable throwable, HttpResponseStatus status) {
35+
public static boolean isStatus(Throwable throwable, HttpResponseStatus... statuses) {
3736
if (throwable instanceof FailedRequestException) {
38-
return ((FailedRequestException) throwable).getStatusCode() == status.code();
37+
final int actualStatus = ((FailedRequestException) throwable).getStatusCode();
38+
for (final HttpResponseStatus status : statuses) {
39+
if (status.code() == actualStatus) {
40+
return true;
41+
}
42+
}
3943
}
4044
return false;
4145
}

src/main/java/me/itzg/helpers/http/FetchBuilderBase.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ private static List<String> applyHeaderRedactions(HttpHeaders headers) {
192192
protected <R> Mono<R> failedRequestMono(HttpClientResponse resp, ByteBufMono bodyMono, String description) {
193193
return (bodyMono != null ? bodyMono.asString() : Mono.just(""))
194194
.defaultIfEmpty("")
195-
.flatMap(body -> Mono.error(new FailedRequestException(resp.status(), uri(), body, description)));
195+
.flatMap(body -> Mono.error(new FailedRequestException(resp.status(), uri(), body, description, resp.responseHeaders())));
196196
}
197197

198198
protected static boolean notSuccess(HttpClientResponse resp) {

0 commit comments

Comments
 (0)