@testdriverai/runner 7.11.62-canary → 7.11.63-canary
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/automation.js +209 -23
- package/lib/upload-retry.test.mjs +162 -0
- package/package.json +1 -1
package/lib/automation.js
CHANGED
|
@@ -72,6 +72,128 @@ function readPngDimensions(buffer) {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// ─── Transient HTTP retry ────────────────────────────────────────────────────
|
|
76
|
+
// S3-compatible stores (we run on Tigris) answer backpressure with 503
|
|
77
|
+
// SlowDown / ServiceUnavailable, and 429 when a rate limit is hit. Those are
|
|
78
|
+
// retryable by contract — every AWS SDK retries them automatically. The
|
|
79
|
+
// screenshot upload path below talks to the store over raw `fetch`, so it has
|
|
80
|
+
// to do that itself or a single throttle blip fails the whole command.
|
|
81
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
82
|
+
const UPLOAD_MAX_ATTEMPTS = 4;
|
|
83
|
+
|
|
84
|
+
// zlib level for encoded screenshots. Overridable so a runner on a CPU-starved
|
|
85
|
+
// instance can trade bytes back for latency without a rebuild.
|
|
86
|
+
const PNG_COMPRESSION_LEVEL = (() => {
|
|
87
|
+
const raw = parseInt(process.env.TD_PNG_COMPRESSION_LEVEL || '', 10);
|
|
88
|
+
return Number.isFinite(raw) && raw >= 0 && raw <= 9 ? raw : 6;
|
|
89
|
+
})();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Exponential backoff with full jitter. Jitter matters more than the base
|
|
93
|
+
* delay here: a fleet of runners throttled by the same bucket would otherwise
|
|
94
|
+
* retry in lockstep and re-trigger the throttle they're backing off from.
|
|
95
|
+
* @param {number} attempt - 1-based attempt number that just failed
|
|
96
|
+
* @returns {number} milliseconds to sleep
|
|
97
|
+
*/
|
|
98
|
+
function backoffMs(attempt) {
|
|
99
|
+
const base = Math.min(500 * 2 ** (attempt - 1), 8000);
|
|
100
|
+
return Math.round(base * (0.5 + Math.random() * 0.5));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetch with retries on transient HTTP statuses and network/timeout errors.
|
|
107
|
+
*
|
|
108
|
+
* Returns the final Response even when it is an error status — the caller
|
|
109
|
+
* decides how to report it — but only after the retry budget is spent.
|
|
110
|
+
*
|
|
111
|
+
* Bounded by BOTH an attempt count and a wall-clock deadline. The deadline is
|
|
112
|
+
* what keeps this honest: retries have to fit inside the per-command timeout
|
|
113
|
+
* the SDK enforces (ably-service uses the command's own timeout, else 120s),
|
|
114
|
+
* or a throttled upload turns into a command timeout with no useful error.
|
|
115
|
+
* Throttles fail in milliseconds, so the deadline only ever bites on genuinely
|
|
116
|
+
* stuck connections — exactly the case where retrying further is pointless.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} url
|
|
119
|
+
* @param {object} init - fetch init (a fresh AbortSignal is set per attempt)
|
|
120
|
+
* @param {object} opts
|
|
121
|
+
* @param {string} opts.label - short name used in logs
|
|
122
|
+
* @param {number} [opts.timeoutMs=20000] - per-attempt timeout
|
|
123
|
+
* @param {number} [opts.deadlineMs=45000] - total wall-clock budget
|
|
124
|
+
* @param {number} [opts.maxAttempts=UPLOAD_MAX_ATTEMPTS]
|
|
125
|
+
* @returns {Promise<Response>}
|
|
126
|
+
*/
|
|
127
|
+
async function fetchWithRetry(url, init, {
|
|
128
|
+
label,
|
|
129
|
+
timeoutMs = 20000,
|
|
130
|
+
deadlineMs = 45000,
|
|
131
|
+
maxAttempts = UPLOAD_MAX_ATTEMPTS,
|
|
132
|
+
}) {
|
|
133
|
+
let lastError;
|
|
134
|
+
const startedAt = Date.now();
|
|
135
|
+
// True when there is no time left to both sleep and give the next attempt a
|
|
136
|
+
// fair chance at completing.
|
|
137
|
+
const outOfTime = (sleepMs) => Date.now() - startedAt + sleepMs >= deadlineMs;
|
|
138
|
+
|
|
139
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
140
|
+
try {
|
|
141
|
+
// A fresh signal per attempt — an AbortSignal that already fired would
|
|
142
|
+
// abort every subsequent retry instantly. Clamp it to whatever is left
|
|
143
|
+
// of the overall budget so the last attempt can't overrun the deadline.
|
|
144
|
+
const remaining = Math.max(1000, deadlineMs - (Date.now() - startedAt));
|
|
145
|
+
const response = await fetch(url, {
|
|
146
|
+
...init,
|
|
147
|
+
signal: AbortSignal.timeout(Math.min(timeoutMs, remaining)),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (response.ok || !RETRYABLE_STATUS.has(response.status)) {
|
|
151
|
+
return response;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Read the body so the reason (SlowDown vs. ServiceUnavailable vs. a
|
|
155
|
+
// gateway page) survives into the logs. Without this every throttle
|
|
156
|
+
// looks identical and is impossible to tell apart after the fact.
|
|
157
|
+
const body = await response.text().catch(() => '');
|
|
158
|
+
console.warn(
|
|
159
|
+
`[automation] ${label} attempt ${attempt}/${maxAttempts} failed: ` +
|
|
160
|
+
`status=${response.status} body=${body.slice(0, 300)}`
|
|
161
|
+
);
|
|
162
|
+
Sentry.addBreadcrumb({
|
|
163
|
+
category: 'upload.retry',
|
|
164
|
+
message: `${label} ${response.status}`,
|
|
165
|
+
level: 'warning',
|
|
166
|
+
data: { attempt, maxAttempts, status: response.status, body: body.slice(0, 300) },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const statusSleep = backoffMs(attempt);
|
|
170
|
+
if (attempt === maxAttempts || outOfTime(statusSleep)) {
|
|
171
|
+
// Re-wrap: the body is already consumed, so hand the caller a
|
|
172
|
+
// response-shaped object it can still read the status and text from.
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
status: response.status,
|
|
176
|
+
text: async () => body,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
lastError = new Error(`${label} failed: ${response.status} ${body.slice(0, 300)}`);
|
|
180
|
+
await sleep(statusSleep);
|
|
181
|
+
continue;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// Network errors and per-attempt timeouts land here.
|
|
184
|
+
lastError = err;
|
|
185
|
+
console.warn(
|
|
186
|
+
`[automation] ${label} attempt ${attempt}/${maxAttempts} errored: ${err.message}`
|
|
187
|
+
);
|
|
188
|
+
const errSleep = backoffMs(attempt);
|
|
189
|
+
if (attempt === maxAttempts || outOfTime(errSleep)) throw err;
|
|
190
|
+
await sleep(errSleep);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
throw lastError;
|
|
195
|
+
}
|
|
196
|
+
|
|
75
197
|
// Log any pyautogui call that takes longer than this. Every call pays a fresh
|
|
76
198
|
// interpreter start plus the `import pyautogui` graph, so the normal range is
|
|
77
199
|
// tens to low-hundreds of ms — anything past this is drift worth seeing before
|
|
@@ -710,8 +832,17 @@ class Automation extends EventEmitter {
|
|
|
710
832
|
// round-trip through base64 (PNG buffer → base64 → PNG buffer) that
|
|
711
833
|
// doubles peak memory and adds ~10–30 ms of pure encoding cost on
|
|
712
834
|
// large screenshots.
|
|
835
|
+
// The SDK sends targetWidth/targetHeight when it is going to downscale
|
|
836
|
+
// the frame on arrival anyway (redraw stability polling compares
|
|
837
|
+
// quarter-scale images). Doing that resize here instead means the
|
|
838
|
+
// bytes never make the round-trip at full resolution. Older SDKs omit
|
|
839
|
+
// it and get a full-size frame, exactly as before.
|
|
840
|
+
const target = (data.targetWidth && data.targetHeight)
|
|
841
|
+
? { width: data.targetWidth, height: data.targetHeight }
|
|
842
|
+
: null;
|
|
843
|
+
|
|
713
844
|
console.log('[automation] Step 1: Capturing screenshot...');
|
|
714
|
-
const buffer = await this._captureScreenshotBuffer();
|
|
845
|
+
const buffer = await this._captureScreenshotBuffer(target);
|
|
715
846
|
console.log(`[automation] Step 2: Screenshot captured, size: ${buffer.length} bytes`);
|
|
716
847
|
|
|
717
848
|
// Read the PNG dimensions from the IHDR header (bytes 16–24) without
|
|
@@ -749,8 +880,8 @@ class Automation extends EventEmitter {
|
|
|
749
880
|
// Propagate Sentry trace context so this request appears under the active command span
|
|
750
881
|
const traceHeaders = Sentry.getTraceData ? Sentry.getTraceData() : {};
|
|
751
882
|
|
|
752
|
-
// Get presigned URL from API (30s
|
|
753
|
-
const response = await
|
|
883
|
+
// Get presigned URL from API (30s per attempt)
|
|
884
|
+
const response = await fetchWithRetry(`${apiRoot}/api/v7/runner/upload-url`, {
|
|
754
885
|
method: 'POST',
|
|
755
886
|
headers: { 'Content-Type': 'application/json', ...traceHeaders },
|
|
756
887
|
body: JSON.stringify({
|
|
@@ -758,8 +889,7 @@ class Automation extends EventEmitter {
|
|
|
758
889
|
sandboxId,
|
|
759
890
|
contentType,
|
|
760
891
|
}),
|
|
761
|
-
|
|
762
|
-
});
|
|
892
|
+
}, { label: 'upload-url' });
|
|
763
893
|
|
|
764
894
|
if (!response.ok) {
|
|
765
895
|
const text = await response.text();
|
|
@@ -769,17 +899,22 @@ class Automation extends EventEmitter {
|
|
|
769
899
|
const { uploadUrl, s3Key } = await response.json();
|
|
770
900
|
console.log(`[automation] Got S3 upload URL, s3Key: ${s3Key}`);
|
|
771
901
|
|
|
772
|
-
// Upload to S3 (30s
|
|
773
|
-
|
|
902
|
+
// Upload to S3 (30s per attempt). The presigned URL is valid for 5
|
|
903
|
+
// minutes, so it comfortably outlives the retry budget and can be reused
|
|
904
|
+
// across attempts rather than re-signed.
|
|
905
|
+
const uploadResponse = await fetchWithRetry(uploadUrl, {
|
|
774
906
|
method: 'PUT',
|
|
775
907
|
headers: { 'Content-Type': contentType },
|
|
776
908
|
body: buffer,
|
|
777
|
-
|
|
778
|
-
});
|
|
909
|
+
}, { label: 's3-upload' });
|
|
779
910
|
|
|
780
911
|
if (!uploadResponse.ok) {
|
|
781
|
-
|
|
782
|
-
|
|
912
|
+
const text = await uploadResponse.text().catch(() => '');
|
|
913
|
+
console.error(
|
|
914
|
+
`[automation] S3 upload failed after ${UPLOAD_MAX_ATTEMPTS} attempts: ` +
|
|
915
|
+
`status=${uploadResponse.status} body=${text.slice(0, 300)}`
|
|
916
|
+
);
|
|
917
|
+
throw new Error(`S3 upload failed: ${uploadResponse.status} ${text.slice(0, 300)}`);
|
|
783
918
|
}
|
|
784
919
|
|
|
785
920
|
console.log(`[automation] S3 upload successful, returning s3Key: ${s3Key}`);
|
|
@@ -795,8 +930,15 @@ class Automation extends EventEmitter {
|
|
|
795
930
|
* PUT to S3): it avoids the wasteful PNG → base64 → PNG round-trip that
|
|
796
931
|
* `_captureScreenshot()` does for backwards compatibility with callers
|
|
797
932
|
* that still expect a base64 string.
|
|
933
|
+
*
|
|
934
|
+
* @param {object} [target] - optional final size, applied after the
|
|
935
|
+
* physical → logical downscale. The SDK passes this when it is going to
|
|
936
|
+
* downscale the frame anyway (redraw stability polling), so the bytes
|
|
937
|
+
* never travel at full resolution just to be thrown away on arrival.
|
|
938
|
+
* @param {number} [target.width]
|
|
939
|
+
* @param {number} [target.height]
|
|
798
940
|
*/
|
|
799
|
-
async _captureScreenshotBuffer() {
|
|
941
|
+
async _captureScreenshotBuffer(target = null) {
|
|
800
942
|
const sharp = require('sharp');
|
|
801
943
|
const maxAttempts = 3;
|
|
802
944
|
let lastError;
|
|
@@ -839,24 +981,47 @@ class Automation extends EventEmitter {
|
|
|
839
981
|
SCREENSHOT_TIMEOUT_MS
|
|
840
982
|
);
|
|
841
983
|
|
|
842
|
-
// Read the PNG and re-encode with sharp
|
|
984
|
+
// Read the PNG and re-encode with sharp
|
|
843
985
|
const pngBuffer = fs.readFileSync(tmpFile);
|
|
844
986
|
const image = sharp(pngBuffer);
|
|
845
987
|
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
//
|
|
988
|
+
// Work out the single final size, then resize once. Two candidates
|
|
989
|
+
// feed into it:
|
|
990
|
+
// 1. logical dimensions — when the grab came back at physical
|
|
991
|
+
// resolution (Retina / DPI scaling), downscale to what pyautogui
|
|
992
|
+
// reports, so coordinates line up with what the agent clicks.
|
|
993
|
+
// 2. an explicit `target` from the caller — the SDK asking for a
|
|
994
|
+
// frame it is only going to shrink anyway.
|
|
995
|
+
// The caller's target wins when both apply. `fit: 'fill'` forces the
|
|
996
|
+
// exact size — the default ('cover') would crop, which would silently
|
|
997
|
+
// shift every coordinate the agent derives from this image. `stats()`
|
|
998
|
+
// below reads the input image, so queuing the resize here doesn't
|
|
999
|
+
// affect black detection.
|
|
851
1000
|
const [physW, physH, logW, logH] = sizeLine.split(/\s+/).map(Number);
|
|
852
1001
|
const haveSizes = [physW, physH, logW, logH].every(
|
|
853
1002
|
(n) => Number.isFinite(n) && n > 0
|
|
854
1003
|
);
|
|
855
|
-
|
|
1004
|
+
|
|
1005
|
+
const wantW = Number(target?.width);
|
|
1006
|
+
const wantH = Number(target?.height);
|
|
1007
|
+
const haveTarget =
|
|
1008
|
+
Number.isFinite(wantW) && Number.isFinite(wantH) && wantW > 0 && wantH > 0;
|
|
1009
|
+
|
|
1010
|
+
let finalW = null;
|
|
1011
|
+
let finalH = null;
|
|
1012
|
+
if (haveTarget) {
|
|
1013
|
+
finalW = Math.round(wantW);
|
|
1014
|
+
finalH = Math.round(wantH);
|
|
1015
|
+
} else if (haveSizes && (physW !== logW || physH !== logH)) {
|
|
1016
|
+
finalW = logW;
|
|
1017
|
+
finalH = logH;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (finalW && finalH && !(finalW === physW && finalH === physH)) {
|
|
856
1021
|
console.log(
|
|
857
|
-
`[automation] screenshot:
|
|
1022
|
+
`[automation] screenshot: resizing ${physW}x${physH} → ${finalW}x${finalH}`
|
|
858
1023
|
);
|
|
859
|
-
image.resize(
|
|
1024
|
+
image.resize(finalW, finalH, { kernel: 'lanczos3', fit: 'fill' });
|
|
860
1025
|
}
|
|
861
1026
|
|
|
862
1027
|
// Detect all-black screenshots (Xvfb/compositor issue)
|
|
@@ -895,7 +1060,19 @@ class Automation extends EventEmitter {
|
|
|
895
1060
|
}
|
|
896
1061
|
}
|
|
897
1062
|
|
|
898
|
-
|
|
1063
|
+
// PNG is lossless at every compression level — the level only trades
|
|
1064
|
+
// encode CPU for bytes on the wire. Level 0 (store, no deflate) was
|
|
1065
|
+
// leaving ~8MB frames to PUT to object storage on every capture,
|
|
1066
|
+
// including the ~10 stability polls a single press-keys triggers.
|
|
1067
|
+
// Screenshots are flat-region-heavy and compress roughly an order of
|
|
1068
|
+
// magnitude at the default level, for tens of ms of libvips CPU
|
|
1069
|
+
// against a capture that already costs hundreds.
|
|
1070
|
+
const out = await image.png({ compressionLevel: PNG_COMPRESSION_LEVEL }).toBuffer();
|
|
1071
|
+
console.log(
|
|
1072
|
+
`[automation] screenshot: encoded ${(out.length / 1024).toFixed(0)}KB ` +
|
|
1073
|
+
`(compressionLevel=${PNG_COMPRESSION_LEVEL})`
|
|
1074
|
+
);
|
|
1075
|
+
return out;
|
|
899
1076
|
} catch (err) {
|
|
900
1077
|
// Retry capture failures here, not just the all-black case above.
|
|
901
1078
|
// Without this a single timeout escapes on the first attempt and the
|
|
@@ -1024,4 +1201,13 @@ class Automation extends EventEmitter {
|
|
|
1024
1201
|
}
|
|
1025
1202
|
}
|
|
1026
1203
|
|
|
1027
|
-
module.exports = {
|
|
1204
|
+
module.exports = {
|
|
1205
|
+
Automation,
|
|
1206
|
+
getDiskSpace,
|
|
1207
|
+
sweepStaleScreenshots,
|
|
1208
|
+
// Exported for unit tests — the upload retry policy is the difference
|
|
1209
|
+
// between a throttled object store and a failed command.
|
|
1210
|
+
fetchWithRetry,
|
|
1211
|
+
RETRYABLE_STATUS,
|
|
1212
|
+
PNG_COMPRESSION_LEVEL,
|
|
1213
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy for the runner's screenshot upload.
|
|
3
|
+
*
|
|
4
|
+
* The object store (Tigris) answers backpressure with 503 SlowDown, which is
|
|
5
|
+
* retryable by contract. Before this policy existed a single throttled PUT
|
|
6
|
+
* failed the whole command — most visibly during press-keys, where redraw
|
|
7
|
+
* stability polling fires a capture every 500ms.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { fetchWithRetry, RETRYABLE_STATUS, PNG_COMPRESSION_LEVEL } = require("./automation.js");
|
|
15
|
+
|
|
16
|
+
/** Start a throwaway HTTP server and return its URL plus a closer. */
|
|
17
|
+
function serve(handler) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const server = http.createServer(handler);
|
|
20
|
+
server.listen(0, () => {
|
|
21
|
+
resolve({
|
|
22
|
+
url: `http://127.0.0.1:${server.address().port}/`,
|
|
23
|
+
close: () => new Promise((r) => server.close(r)),
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Responds with `status` for the first `count` requests, then 200. */
|
|
30
|
+
function failThen(status, count, state) {
|
|
31
|
+
return (req, res) => {
|
|
32
|
+
state.attempts++;
|
|
33
|
+
if (state.attempts <= count) {
|
|
34
|
+
res.writeHead(status);
|
|
35
|
+
res.end("<Error><Code>SlowDown</Code></Error>");
|
|
36
|
+
} else {
|
|
37
|
+
res.writeHead(200);
|
|
38
|
+
res.end("ok");
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("fetchWithRetry", () => {
|
|
44
|
+
it("recovers from transient 503s instead of failing the command", async () => {
|
|
45
|
+
const state = { attempts: 0 };
|
|
46
|
+
const server = await serve(failThen(503, 2, state));
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetchWithRetry(
|
|
50
|
+
server.url,
|
|
51
|
+
{ method: "PUT", body: Buffer.from("png-bytes") },
|
|
52
|
+
{ label: "s3-upload" },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(res.ok).toBe(true);
|
|
56
|
+
expect(state.attempts).toBe(3);
|
|
57
|
+
} finally {
|
|
58
|
+
await server.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("re-sends the body intact on every attempt", async () => {
|
|
63
|
+
// A stream body would be consumed by the first attempt and every retry
|
|
64
|
+
// would upload zero bytes — silently storing truncated screenshots.
|
|
65
|
+
const received = [];
|
|
66
|
+
const server = await serve((req, res) => {
|
|
67
|
+
let len = 0;
|
|
68
|
+
req.on("data", (c) => { len += c.length; });
|
|
69
|
+
req.on("end", () => {
|
|
70
|
+
received.push(len);
|
|
71
|
+
if (received.length < 3) { res.writeHead(503); res.end("SlowDown"); }
|
|
72
|
+
else { res.writeHead(200); res.end("ok"); }
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const res = await fetchWithRetry(
|
|
77
|
+
server.url,
|
|
78
|
+
{ method: "PUT", body: Buffer.alloc(5000, 7) },
|
|
79
|
+
{ label: "s3-upload" },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(res.ok).toBe(true);
|
|
83
|
+
expect(received).toEqual([5000, 5000, 5000]);
|
|
84
|
+
await server.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not retry a non-retryable status", async () => {
|
|
88
|
+
// A 403 means the presigned URL is wrong — retrying only delays the error.
|
|
89
|
+
const state = { attempts: 0 };
|
|
90
|
+
const server = await serve((req, res) => {
|
|
91
|
+
state.attempts++;
|
|
92
|
+
res.writeHead(403);
|
|
93
|
+
res.end("denied");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const res = await fetchWithRetry(server.url, { method: "PUT" }, { label: "s3-upload" });
|
|
97
|
+
|
|
98
|
+
expect(res.ok).toBe(false);
|
|
99
|
+
expect(res.status).toBe(403);
|
|
100
|
+
expect(state.attempts).toBe(1);
|
|
101
|
+
await server.close();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("surfaces the store's error body so throttles stay diagnosable", async () => {
|
|
105
|
+
const server = await serve((req, res) => {
|
|
106
|
+
res.writeHead(503);
|
|
107
|
+
res.end("<Error><Code>SlowDown</Code></Error>");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const res = await fetchWithRetry(
|
|
111
|
+
server.url,
|
|
112
|
+
{ method: "PUT" },
|
|
113
|
+
{ label: "s3-upload", deadlineMs: 1500 },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(res.ok).toBe(false);
|
|
117
|
+
expect(res.status).toBe(503);
|
|
118
|
+
expect(await res.text()).toContain("SlowDown");
|
|
119
|
+
await server.close();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("stops retrying at the deadline so it fits inside the command timeout", async () => {
|
|
123
|
+
const state = { attempts: 0 };
|
|
124
|
+
const server = await serve((req, res) => {
|
|
125
|
+
state.attempts++;
|
|
126
|
+
res.writeHead(503);
|
|
127
|
+
res.end("SlowDown");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
const res = await fetchWithRetry(
|
|
132
|
+
server.url,
|
|
133
|
+
{ method: "PUT" },
|
|
134
|
+
{ label: "s3-upload", deadlineMs: 1200 },
|
|
135
|
+
);
|
|
136
|
+
const elapsed = Date.now() - startedAt;
|
|
137
|
+
|
|
138
|
+
expect(res.ok).toBe(false);
|
|
139
|
+
// Gave up early rather than burning the full 4-attempt budget.
|
|
140
|
+
expect(state.attempts).toBeLessThan(4);
|
|
141
|
+
expect(elapsed).toBeLessThan(1200);
|
|
142
|
+
await server.close();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("treats the statuses an object store uses for backpressure as retryable", () => {
|
|
146
|
+
for (const status of [429, 500, 502, 503, 504]) {
|
|
147
|
+
expect(RETRYABLE_STATUS.has(status)).toBe(true);
|
|
148
|
+
}
|
|
149
|
+
for (const status of [400, 403, 404]) {
|
|
150
|
+
expect(RETRYABLE_STATUS.has(status)).toBe(false);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("screenshot encoding", () => {
|
|
156
|
+
it("compresses PNGs rather than storing them raw", () => {
|
|
157
|
+
// Level 0 is store-only: it was putting ~8MB frames on the wire for every
|
|
158
|
+
// capture, including ~10 stability polls per press-keys.
|
|
159
|
+
expect(PNG_COMPRESSION_LEVEL).toBeGreaterThan(0);
|
|
160
|
+
expect(PNG_COMPRESSION_LEVEL).toBeLessThanOrEqual(9);
|
|
161
|
+
});
|
|
162
|
+
});
|