diff --git a/README.md b/README.md
index 136bc3c..c73e77b 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,8 @@ jobs:
8
16
+ retry-attempts: 2
+ rettry-delay: 10000
```
### Minimalistic Example
@@ -104,6 +106,8 @@ jobs:
| [version-resolver](#user-content-version-resolver) | Determines the way automatic [`game-versions`](#user-content-game-versions) resolvement works | `releasesIfAny` | `exact`
`latest`
`all`
`releases`
`releasesIfAny` |
| [dependencies](#user-content-dependencies) | A list of dependencies | A dependency list specified in the config file | `fabric \| depends \| 0.40.0`
`fabric-api` |
| [java](#user-content-java) | A list of supported Java versions | *empty string* | `Java 8`
`Java 1.8`
`8` |
+| [retry-attempts](#user-content-retry-attempts) | The maximum number of attempts to publish assets | `2` | `2`
`10`
`-1` |
+| [retry-delay](#user-content-retry-delay) | Time delay between attempts to publish assets (in milliseconds) | `10000` | `10000`
`60000`
`0` |
#### modrinth-id
@@ -501,4 +505,20 @@ java: |
8
16
Java 17
+```
+
+#### retry-attempts
+
+The maximum number of attempts to publish assets.
+
+```yaml
+retry-attempts: 2
+```
+
+#### retry-delay
+
+Time delay between attempts to publish assets (in milliseconds).
+
+```yaml
+retry-delay: 10000
```
\ No newline at end of file
diff --git a/action.yml b/action.yml
index df33086..2f4cc2b 100644
--- a/action.yml
+++ b/action.yml
@@ -87,6 +87,15 @@ inputs:
description: A list of supported Java versions
required: false
default: ${undefined}
+
+ retry-attempts:
+ description: The maximum number of attempts to publish assets
+ required: false
+ default: 2
+ retry-delay:
+ description: Time delay between attempts to publish assets (in milliseconds)
+ required: false
+ default: 10000
runs:
using: node12
main: dist/index.js
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index ff2b6ab..a2ad172 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,6 +3,7 @@ import PublisherFactory from "./publishing/publisher-factory";
import PublisherTarget from "./publishing/publisher-target";
import { getInputAsObject } from "./utils/input-utils";
import { getDefaultLogger } from "./utils/logger-utils";
+import { retry } from "./utils/function-utils";
async function main() {
const commonOptions = getInputAsObject();
@@ -20,12 +21,25 @@ async function main() {
const options = { ...commonOptions, ...publisherOptions };
const fileSelector = options.files && (typeof(options.files) === "string" || options.files.primary) ? options.files : gradleOutputSelector;
const files = await getRequiredFiles(fileSelector);
+ const retryAttempts = +options.retry?.["attempts"] || 0;
+ const retryDelay = +options.retry?.["delay"] || 0;
const publisher = publisherFactory.create(target, logger);
logger.info(`Publishing assets to ${targetName}...`);
const start = new Date();
- await publisher.publish(files, options);
- logger.info(`Successfully published assets to ${targetName} (in ${new Date().getTime() - start.getTime()}ms)`);
+
+ await retry({
+ func: () => publisher.publish(files, options),
+ maxAttempts: retryAttempts,
+ delay: retryDelay,
+ errorCallback: e => {
+ logger.error(`${e}`);
+ logger.info(`Retrying to publish assets to ${targetName} in ${retryDelay} ms...`);
+ }
+ });
+
+ const end = new Date();
+ logger.info(`Successfully published assets to ${targetName} (in ${end.getTime() - start.getTime()} ms)`);
publishedTo.push(targetName);
}
@@ -36,4 +50,4 @@ async function main() {
}
}
-main().catch(error => getDefaultLogger().fatal(error instanceof Error ? error.message : `Something went horribly wrong: ${error}`));
+main().catch(error => getDefaultLogger().fatal(error instanceof Error ? `${error}` : `Something went horribly wrong: ${error}`));
diff --git a/src/utils/curseforge-utils.ts b/src/utils/curseforge-utils.ts
index 5414932..de40fe7 100644
--- a/src/utils/curseforge-utils.ts
+++ b/src/utils/curseforge-utils.ts
@@ -3,6 +3,7 @@ import { FormData } from "formdata-node";
import { fileFromPath } from "formdata-node/file-from-path";
import { File } from "./file";
import { findVersionByName } from "./minecraft-utils";
+import SoftError from "./soft-error";
const baseUrl = "https://minecraft.curseforge.com/api";
@@ -24,11 +25,11 @@ interface CurseForgeUploadErrorInfo {
errorMessage: string;
}
-class CurseForgeUploadError extends Error {
+class CurseForgeUploadError extends SoftError {
public readonly info?: CurseForgeUploadErrorInfo;
- constructor(message: string, info?: CurseForgeUploadErrorInfo) {
- super(message);
+ constructor(soft: boolean, message?: string, info?: CurseForgeUploadErrorInfo) {
+ super(soft, message);
this.info = info;
}
}
@@ -117,7 +118,8 @@ export async function uploadFile(id: string, data: Record, file: Fi
info = await response.json();
errorText += `, ${JSON.stringify(info)}`;
} catch { }
- throw new CurseForgeUploadError(`Failed to upload file: ${response.status} (${errorText})`, info);
+ const isServerError = response.status >= 500;
+ throw new CurseForgeUploadError(isServerError, `Failed to upload file: ${response.status} (${errorText})`, info);
}
return (<{ id: number }>await response.json()).id;
diff --git a/src/utils/function-utils.ts b/src/utils/function-utils.ts
new file mode 100644
index 0000000..bf90622
--- /dev/null
+++ b/src/utils/function-utils.ts
@@ -0,0 +1,19 @@
+import sleep from "./sleep";
+
+export async function retry({ func, delay = 0, maxAttempts = -1, softErrorPredicate, errorCallback }: { func: () => T | Promise, delay?: number, maxAttempts?: number, softErrorPredicate?: (error: unknown) => boolean, errorCallback?: (error: unknown) => void }): Promise {
+ let attempts = 0;
+ while (true) {
+ try {
+ return await func();
+ } catch (e) {
+ const isSoft = softErrorPredicate ? softErrorPredicate(e) : e?.soft;
+ if (!isSoft || maxAttempts >= 0 && ++attempts >= maxAttempts ) {
+ throw e;
+ }
+ if (errorCallback) {
+ errorCallback(e);
+ }
+ }
+ await sleep(delay);
+ }
+}
diff --git a/src/utils/modrinth-utils.ts b/src/utils/modrinth-utils.ts
index 9af2d32..8bc881b 100644
--- a/src/utils/modrinth-utils.ts
+++ b/src/utils/modrinth-utils.ts
@@ -3,6 +3,7 @@ import { fileFromPath } from "formdata-node/file-from-path";
import fetch from "node-fetch";
import { File } from "./file";
import { computeHash } from "./hash-utils";
+import SoftError from "./soft-error";
export async function createVersion(modId: string, data: Record, files: File[], token: string): Promise {
data = {
@@ -30,7 +31,8 @@ export async function createVersion(modId: string, data: Record, fi
try {
errorText += `, ${await response.text()}`;
} catch { }
- throw new Error(`Failed to upload file: ${response.status} (${errorText})`);
+ const isServerError = response.status >= 500;
+ throw new SoftError(isServerError, `Failed to upload file: ${response.status} (${errorText})`);
}
const versionId = (<{ id: string }>await response.json()).id;
diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts
new file mode 100644
index 0000000..ffbc47d
--- /dev/null
+++ b/src/utils/sleep.ts
@@ -0,0 +1,3 @@
+export default function sleep(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
diff --git a/src/utils/soft-error.ts b/src/utils/soft-error.ts
new file mode 100644
index 0000000..a9a452b
--- /dev/null
+++ b/src/utils/soft-error.ts
@@ -0,0 +1,8 @@
+export default class SoftError extends Error {
+ readonly soft: boolean;
+
+ constructor(soft: boolean, message?: string) {
+ super(message);
+ this.soft = soft;
+ }
+}
diff --git a/test/function-utils.test.ts b/test/function-utils.test.ts
new file mode 100644
index 0000000..6585f48
--- /dev/null
+++ b/test/function-utils.test.ts
@@ -0,0 +1,70 @@
+import { describe, test, expect } from "@jest/globals";
+import { retry } from "../src/utils/function-utils";
+import SoftError from "../src/utils/soft-error";
+
+function createThrowingFunc(attempts: number): () => true {
+ let counter = 0;
+ return () => {
+ if (++counter !== attempts) {
+ throw new SoftError(true);
+ }
+ return true;
+ };
+}
+
+function createAsyncThrowingFunc(attempts: number): () => Promise {
+ const func = createThrowingFunc(attempts);
+ return async () => func();
+}
+
+describe("retry", () => {
+ test("function resolves after several attempts", async () => {
+ expect(await retry({ func: createThrowingFunc(5), maxAttempts: 5 })).toBe(true);
+ });
+
+ test("delay is applied between the attempts", async () => {
+ const start = new Date();
+ expect(await retry({ func: createThrowingFunc(2), maxAttempts: 2, delay: 100 })).toBe(true);
+ const end = new Date();
+ const duration = end.getTime() - start.getTime();
+ expect(duration >= 100 && duration < 200).toBe(true);
+ });
+
+ test("the original error is thrown if retry function didn't succeed", async () => {
+ await expect(retry({ func: createThrowingFunc(5), maxAttempts: 1 })).rejects.toThrow(SoftError);
+ });
+
+ test("softErrorPredicate is used to determine whether the error is soft or not", async () => {
+ await expect(retry({ func: createThrowingFunc(5), maxAttempts: 5, softErrorPredicate: _ => false })).rejects.toThrow(SoftError);
+ });
+
+ test("errorCallback is called whenever an error occurs", async () => {
+ await expect(retry({ func: createThrowingFunc(5), maxAttempts: 5, errorCallback: e => { throw e; } })).rejects.toThrow(SoftError);
+ });
+});
+
+describe("retry (async)", () => {
+ test("function resolves after several attempts", async () => {
+ expect(await retry({ func: createAsyncThrowingFunc(5), maxAttempts: 5 })).toBe(true);
+ });
+
+ test("delay is applied between the attempts", async () => {
+ const start = new Date();
+ expect(await retry({ func: createAsyncThrowingFunc(2), maxAttempts: 2, delay: 100 })).toBe(true);
+ const end = new Date();
+ const duration = end.getTime() - start.getTime();
+ expect(duration >= 100 && duration < 200).toBe(true);
+ });
+
+ test("the original error is thrown if retry function didn't succeed", async () => {
+ await expect(retry({ func: createAsyncThrowingFunc(5), maxAttempts: 1 })).rejects.toThrow(SoftError);
+ });
+
+ test("softErrorPredicate is used to determine whether the error is soft or not", async () => {
+ await expect(retry({ func: createAsyncThrowingFunc(5), maxAttempts: 5, softErrorPredicate: _ => false })).rejects.toThrow(SoftError);
+ });
+
+ test("errorCallback is called whenever an error occurs", async () => {
+ await expect(retry({ func: createAsyncThrowingFunc(5), maxAttempts: 5, errorCallback: e => { throw e; } })).rejects.toThrow(SoftError);
+ });
+});