-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsteadybitAPI.js
More file actions
137 lines (125 loc) · 5.72 KB
/
Copy pathsteadybitAPI.js
File metadata and controls
137 lines (125 loc) · 5.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
/*
* Copyright 2023 steadybit GmbH. All rights reserved.
*/
import axios from 'axios';
import * as core from '@actions/core';
import { delay } from './util.js';
export class SteadybitAPI {
allowParallelBackoffInterval = 30;
executionStateQueryInterval = 3;
constructor(baseURL, apiAccessToken, httpFactory = axios.create) {
this.http = httpFactory({
baseURL,
headers: { Authorization: `accessToken ${apiAccessToken}`, 'Content-Type': 'application/json' },
});
}
async runExperiment(experimentKey, allowParallel = false, retries = 3, validationRetries = 0, validationRetryInterval = 15) {
for (let attempt = 0; attempt <= validationRetries; attempt++) {
const isLastAttempt = attempt === validationRetries;
const forcePersist = isLastAttempt ? 'true' : 'false';
try {
const response = await this.http.post(`/api/experiments/${experimentKey}/execute`, null, {
params: { allowParallel: String(allowParallel), forcePersist },
});
return response.headers.location;
} catch (error) {
const responseBody = error.response?.data;
if (responseBody?.status === 422 && responseBody?.title.match(/Another.*running/) !== null && retries > 0) {
core.info(`Another experiment is running, retrying in ${this.allowParallelBackoffInterval} seconds.`);
await delay(this.allowParallelBackoffInterval * 1000);
return this.runExperiment(experimentKey, allowParallel, retries - 1, validationRetries, validationRetryInterval);
} else if (error.response?.status === 422 && attempt < validationRetries) {
core.info(`Experiment has validation errors (attempt ${attempt + 1}/${validationRetries + 1}). Retrying in ${validationRetryInterval}s...`);
await delay(validationRetryInterval * 1000);
continue;
} else {
throw this._getErrorFromResponse(error);
}
}
}
}
async getExperiment(experimentKey) {
try {
const response = await this.http.get(`/api/experiments/${experimentKey}`);
return response.data;
} catch (error) {
throw this._getErrorFromResponse(error);
}
}
async lookupByExternalId(externalId) {
try {
const response = await this.http.get(`/api/experiments`, { params: { externalId } });
const experiments = response?.data.experiments;
if (experiments && experiments.length === 1) {
return experiments[0].key;
} else {
throw `Experiment with externalId '${externalId}' could not be found.`;
}
} catch (error) {
throw this._getErrorFromResponse(error);
}
}
async awaitExecutionState(url, expectedState, expectedReason) {
try {
const response = await this.http.get(url);
const execution = response?.data;
if (execution?.state === expectedState) {
return this._executionEndedInExpectedState(execution, expectedReason);
} else {
return this._executionEndedInDifferentState(url, execution, expectedState, expectedReason);
}
} catch (error) {
throw this._getErrorFromResponse(error);
}
}
async _executionEndedInExpectedState(execution, expectedReason) {
const executionReason = execution?.failureReason || execution?.reason;
if (!expectedReason || expectedReason === executionReason) {
return {
id: execution.id,
state: execution.state,
reason: execution.failureReason || execution.reason || undefined,
};
} else {
throw this._executionMismatchError(
`Execution ${execution.id} ended with '${execution.state}'. Expected failure reason '${expectedReason}', but was '${executionReason}'`,
execution,
executionReason,
);
}
}
async _executionEndedInDifferentState(url, execution, expectedState, expectedReason) {
const executionReason = execution?.failureReason || execution?.reason;
if (execution && execution.ended) {
throw this._executionMismatchError(
`Execution ${execution.id} ended with '${execution.state}${executionReason ? ` - ${executionReason}` : ''}' but expected '${expectedState}${
expectedReason ? ` - ${expectedReason}` : ''
}'`,
execution,
executionReason,
);
} else {
await delay(this.executionStateQueryInterval * 1000);
return this.awaitExecutionState(url, expectedState, expectedReason);
}
}
// A terminated execution that did not match the expectation is a real failure,
// but the execution still exists — attach its identity so the caller can still
// expose executionId/State/Reason (and thus a link) instead of dropping them.
_executionMismatchError(message, execution, executionReason) {
const error = new Error(message);
error.execution = {
id: execution?.id,
state: execution?.state,
reason: executionReason || undefined,
};
return error;
}
_getErrorFromResponse(error) {
core.debug(JSON.stringify(error));
if (error.response && error.response.data) {
return error.response.data.title ? error.response.data.title : JSON.stringify(error.response.data);
}
return error.toString();
}
}