From fbda2b1cfe3459b9939852b38c14502a7adf0a6e Mon Sep 17 00:00:00 2001 From: Kir_Antipov Date: Wed, 12 Jan 2022 18:16:19 +0300 Subject: [PATCH] Added the ability to retry publishing assets --- README.md | 20 ++++++++++ action.yml | 9 +++++ src/index.ts | 20 ++++++++-- src/utils/curseforge-utils.ts | 10 +++-- src/utils/function-utils.ts | 19 ++++++++++ src/utils/modrinth-utils.ts | 4 +- src/utils/sleep.ts | 3 ++ src/utils/soft-error.ts | 8 ++++ test/function-utils.test.ts | 70 +++++++++++++++++++++++++++++++++++ 9 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 src/utils/function-utils.ts create mode 100644 src/utils/sleep.ts create mode 100644 src/utils/soft-error.ts create mode 100644 test/function-utils.test.ts 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); + }); +});