Added the ability to retry publishing assets

This commit is contained in:
Kir_Antipov 2022-01-12 18:16:19 +03:00
parent b98fa4496b
commit fbda2b1cfe
9 changed files with 155 additions and 8 deletions

View file

@ -53,6 +53,8 @@ jobs:
8 8
16 16
retry-attempts: 2
rettry-delay: 10000
``` ```
### Minimalistic Example ### 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` <br> `latest` <br> `all` <br> `releases` <br> `releasesIfAny` | | [version-resolver](#user-content-version-resolver) | Determines the way automatic [`game-versions`](#user-content-game-versions) resolvement works | `releasesIfAny` | `exact` <br> `latest` <br> `all` <br> `releases` <br> `releasesIfAny` |
| [dependencies](#user-content-dependencies) | A list of dependencies | A dependency list specified in the config file | `fabric \| depends \| 0.40.0` <br> `fabric-api` | | [dependencies](#user-content-dependencies) | A list of dependencies | A dependency list specified in the config file | `fabric \| depends \| 0.40.0` <br> `fabric-api` |
| [java](#user-content-java) | A list of supported Java versions | *empty string* | `Java 8` <br> `Java 1.8` <br> `8` | | [java](#user-content-java) | A list of supported Java versions | *empty string* | `Java 8` <br> `Java 1.8` <br> `8` |
| [retry-attempts](#user-content-retry-attempts) | The maximum number of attempts to publish assets | `2` | `2` <br> `10` <br> `-1` |
| [retry-delay](#user-content-retry-delay) | Time delay between attempts to publish assets (in milliseconds) | `10000` | `10000` <br> `60000` <br> `0` |
#### modrinth-id #### modrinth-id
@ -502,3 +506,19 @@ java: |
16 16
Java 17 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
```

View file

@ -87,6 +87,15 @@ inputs:
description: A list of supported Java versions description: A list of supported Java versions
required: false required: false
default: ${undefined} 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: runs:
using: node12 using: node12
main: dist/index.js main: dist/index.js

View file

@ -3,6 +3,7 @@ import PublisherFactory from "./publishing/publisher-factory";
import PublisherTarget from "./publishing/publisher-target"; import PublisherTarget from "./publishing/publisher-target";
import { getInputAsObject } from "./utils/input-utils"; import { getInputAsObject } from "./utils/input-utils";
import { getDefaultLogger } from "./utils/logger-utils"; import { getDefaultLogger } from "./utils/logger-utils";
import { retry } from "./utils/function-utils";
async function main() { async function main() {
const commonOptions = getInputAsObject(); const commonOptions = getInputAsObject();
@ -20,12 +21,25 @@ async function main() {
const options = { ...commonOptions, ...publisherOptions }; const options = { ...commonOptions, ...publisherOptions };
const fileSelector = options.files && (typeof(options.files) === "string" || options.files.primary) ? options.files : gradleOutputSelector; const fileSelector = options.files && (typeof(options.files) === "string" || options.files.primary) ? options.files : gradleOutputSelector;
const files = await getRequiredFiles(fileSelector); const files = await getRequiredFiles(fileSelector);
const retryAttempts = +options.retry?.["attempts"] || 0;
const retryDelay = +options.retry?.["delay"] || 0;
const publisher = publisherFactory.create(target, logger); const publisher = publisherFactory.create(target, logger);
logger.info(`Publishing assets to ${targetName}...`); logger.info(`Publishing assets to ${targetName}...`);
const start = new Date(); 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); 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}`));

View file

@ -3,6 +3,7 @@ import { FormData } from "formdata-node";
import { fileFromPath } from "formdata-node/file-from-path"; import { fileFromPath } from "formdata-node/file-from-path";
import { File } from "./file"; import { File } from "./file";
import { findVersionByName } from "./minecraft-utils"; import { findVersionByName } from "./minecraft-utils";
import SoftError from "./soft-error";
const baseUrl = "https://minecraft.curseforge.com/api"; const baseUrl = "https://minecraft.curseforge.com/api";
@ -24,11 +25,11 @@ interface CurseForgeUploadErrorInfo {
errorMessage: string; errorMessage: string;
} }
class CurseForgeUploadError extends Error { class CurseForgeUploadError extends SoftError {
public readonly info?: CurseForgeUploadErrorInfo; public readonly info?: CurseForgeUploadErrorInfo;
constructor(message: string, info?: CurseForgeUploadErrorInfo) { constructor(soft: boolean, message?: string, info?: CurseForgeUploadErrorInfo) {
super(message); super(soft, message);
this.info = info; this.info = info;
} }
} }
@ -117,7 +118,8 @@ export async function uploadFile(id: string, data: Record<string, any>, file: Fi
info = <CurseForgeUploadErrorInfo>await response.json(); info = <CurseForgeUploadErrorInfo>await response.json();
errorText += `, ${JSON.stringify(info)}`; errorText += `, ${JSON.stringify(info)}`;
} catch { } } 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; return (<{ id: number }>await response.json()).id;

View file

@ -0,0 +1,19 @@
import sleep from "./sleep";
export async function retry<T>({ func, delay = 0, maxAttempts = -1, softErrorPredicate, errorCallback }: { func: () => T | Promise<T>, delay?: number, maxAttempts?: number, softErrorPredicate?: (error: unknown) => boolean, errorCallback?: (error: unknown) => void }): Promise<T> {
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);
}
}

View file

@ -3,6 +3,7 @@ import { fileFromPath } from "formdata-node/file-from-path";
import fetch from "node-fetch"; import fetch from "node-fetch";
import { File } from "./file"; import { File } from "./file";
import { computeHash } from "./hash-utils"; import { computeHash } from "./hash-utils";
import SoftError from "./soft-error";
export async function createVersion(modId: string, data: Record<string, any>, files: File[], token: string): Promise<string> { export async function createVersion(modId: string, data: Record<string, any>, files: File[], token: string): Promise<string> {
data = { data = {
@ -30,7 +31,8 @@ export async function createVersion(modId: string, data: Record<string, any>, fi
try { try {
errorText += `, ${await response.text()}`; errorText += `, ${await response.text()}`;
} catch { } } 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; const versionId = (<{ id: string }>await response.json()).id;

3
src/utils/sleep.ts Normal file
View file

@ -0,0 +1,3 @@
export default function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

8
src/utils/soft-error.ts Normal file
View file

@ -0,0 +1,8 @@
export default class SoftError extends Error {
readonly soft: boolean;
constructor(soft: boolean, message?: string) {
super(message);
this.soft = soft;
}
}

View file

@ -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<true> {
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(<any>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(<any>SoftError);
});
test("errorCallback is called whenever an error occurs", async () => {
await expect(retry({ func: createThrowingFunc(5), maxAttempts: 5, errorCallback: e => { throw e; } })).rejects.toThrow(<any>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(<any>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(<any>SoftError);
});
test("errorCallback is called whenever an error occurs", async () => {
await expect(retry({ func: createAsyncThrowingFunc(5), maxAttempts: 5, errorCallback: e => { throw e; } })).rejects.toThrow(<any>SoftError);
});
});