heygen-com/hyperframes · error
Direct upload PUT failed: ${res.status} ${res.statusText}${d
Error message
Direct upload PUT failed: ${res.status} ${res.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ""} What it means
Thrown when the S3/GCS direct-upload PUT (the second leg of the multipart asset upload) returns a non-2xx response. The message includes the HTTP status, statusText, and up to 300 chars of the response body for diagnosis. It fires inside the PUT step itself; the separate completeWithRetry step handles the finalize race and is not where this error originates.
Source
Thrown at packages/cli/src/cloud/upload.ts:103
uploadUrl: string,
uploadHeaders: Record<string, unknown>,
bytes: Uint8Array,
): Promise<void> {
const headers: Record<string, string> = {
"content-type": CONTENT_TYPE_ZIP,
...normalizeUploadHeaders(uploadHeaders),
};
// `Uint8Array<ArrayBufferLike>` is a valid `BodyInit` at runtime but
// not strictly assignable per lib.dom.d.ts — cast rather than copy,
// since a 200MB buffer copy would be wasteful.
const res = await fetchImpl(uploadUrl, {
method: "PUT",
headers,
body: bytes as unknown as BodyInit,
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(
`Direct upload PUT failed: ${res.status} ${res.statusText}${
detail ? ` — ${detail.slice(0, 300)}` : ""
}`,
);
}
}
// Complete with retry-on-409. Retry ONLY on the documented "PUT not
// visible yet" race between S3 write consistency and finalize; any other
// error surfaces immediately. `completeAssetUpload` itself is idempotent,
// so retrying an already-succeeded call is safe.
async function completeWithRetry(
client: HyperframesCloudClient,
asset_id: string,
checksum_sha256: string,
): Promise<{ asset_id: string }> {
let lastErr: unknown;
for (let attempt = 0; attempt < COMPLETE_MAX_RETRIES; attempt++) {View on GitHub (pinned to c2996c8626)
Solutions
- Re-reserve the upload (call the asset-create/initiate endpoint again) to get a fresh presigned URL, then retry the PUT immediately.
- Strip any custom uploadHeaders that were not part of the signed header set, or have the server sign the headers you need.
- Verify host clock skew (AWS SigV4 rejects requests more than a few minutes off).
- If the asset is large, switch to multipart upload rather than a single PUT.
Example fix
// before: custom header breaks the signature
await directUploadPut(url, bytes, { 'Content-Encoding': 'br' });
// after: only send headers the presigned URL signed, and refresh on 403
try {
await directUploadPut(url, bytes);
} catch (err) {
if (/PUT failed: 403/.test(String(err?.message))) {
const fresh = await reserveUpload(assetId);
await directUploadPut(fresh.url, bytes);
} else throw err;
} Defensive patterns
Strategy: retry
Validate before calling
// Pre-flight: ensure headers you send are part of the signed set
function assertSignedHeaders(uploadUrl: URL, headers: Record<string,string>) {
// presigned URLs typically encode signed headers in the querystring
const signed = new Set(uploadUrl.searchParams.keys());
for (const h of Object.keys(headers)) {
if (!signed.has(h.toLowerCase())) throw new Error(`header ${h} not in signed set`);
}
} Try / catch
for (let attempt = 0; attempt < 3; attempt++) {
try {
await directUploadPut(uploadUrl, bytes);
break;
} catch (err) {
if (/PUT failed: 40[03]/.test(String(err?.message)) && attempt < 2) {
uploadUrl = (await reserveUpload(id)).url; // refresh
continue;
}
throw err;
}
} Prevention
- Do not inject uploadHeaders that were not part of the presigned signature.
- Issue the PUT immediately after reserving the URL to avoid expiry.
- Verify host clock skew when signature errors persist on a fresh URL.
When it happens
Trigger: The presigned PUT URL expired before the bytes were sent (403), a signature/headers mismatch caused by overriding Content-Type or adding headers not in the signed set, the object exceeds a bucket size policy, or the storage backend returned 5xx.
Common situations: Long delay between reserving the upload and issuing the PUT; injecting custom uploadHeaders that break the signature (e.g. adding Content-Encoding the presigned URL did not sign); uploading a >5GB single PUT where multipart was required; clock skew on the signing client.
Related errors
- Failed to download ${url}: HTTP ${res.status} ${res.statusTe
- Truncated download: got ${bytes} bytes, expected ${totalOpt}
- Failed to upload project archive
- [s3Transport] upload source missing: ${localPath}
- Invalid JSON response: ${(err as Error).message}
AI-assisted analysis of heygen-com/hyperframes@c2996c8626 (2026-08-12).
Data as JSON: /api/errors/3d7155731c1bc78a.
Report an issue: GitHub.