{"record":{"id":"3d7155731c1bc78a","repo":"heygen-com/hyperframes","slug":"direct-upload-put-failed-res-status-res-stat","errorCode":null,"errorMessage":"Direct upload PUT failed: ${res.status} ${res.statusText}${detail ? ` — ${detail.slice(0, 300)}` : \"\"}","messagePattern":"Direct upload PUT failed: (.+?) (.+?)(.+?)` : \"\"\\}","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/cli/src/cloud/upload.ts","lineNumber":103,"sourceCode":"  uploadUrl: string,\n  uploadHeaders: Record<string, unknown>,\n  bytes: Uint8Array,\n): Promise<void> {\n  const headers: Record<string, string> = {\n    \"content-type\": CONTENT_TYPE_ZIP,\n    ...normalizeUploadHeaders(uploadHeaders),\n  };\n  // `Uint8Array<ArrayBufferLike>` is a valid `BodyInit` at runtime but\n  // not strictly assignable per lib.dom.d.ts — cast rather than copy,\n  // since a 200MB buffer copy would be wasteful.\n  const res = await fetchImpl(uploadUrl, {\n    method: \"PUT\",\n    headers,\n    body: bytes as unknown as BodyInit,\n  });\n  if (!res.ok) {\n    const detail = await res.text().catch(() => \"\");\n    throw new Error(\n      `Direct upload PUT failed: ${res.status} ${res.statusText}${\n        detail ? ` — ${detail.slice(0, 300)}` : \"\"\n      }`,\n    );\n  }\n}\n\n// Complete with retry-on-409. Retry ONLY on the documented \"PUT not\n// visible yet\" race between S3 write consistency and finalize; any other\n// error surfaces immediately. `completeAssetUpload` itself is idempotent,\n// so retrying an already-succeeded call is safe.\nasync function completeWithRetry(\n  client: HyperframesCloudClient,\n  asset_id: string,\n  checksum_sha256: string,\n): Promise<{ asset_id: string }> {\n  let lastErr: unknown;\n  for (let attempt = 0; attempt < COMPLETE_MAX_RETRIES; attempt++) {","sourceCodeStart":85,"sourceCodeEnd":121,"githubUrl":"https://github.com/heygen-com/hyperframes/blob/c2996c8626135db5253519359d8a063d3bafad8d/packages/cli/src/cloud/upload.ts#L85-L121","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: custom header breaks the signature\nawait directUploadPut(url, bytes, { 'Content-Encoding': 'br' });\n\n// after: only send headers the presigned URL signed, and refresh on 403\ntry {\n  await directUploadPut(url, bytes);\n} catch (err) {\n  if (/PUT failed: 403/.test(String(err?.message))) {\n    const fresh = await reserveUpload(assetId);\n    await directUploadPut(fresh.url, bytes);\n  } else throw err;\n}","handlingStrategy":"retry","validationCode":"// Pre-flight: ensure headers you send are part of the signed set\nfunction assertSignedHeaders(uploadUrl: URL, headers: Record<string,string>) {\n  // presigned URLs typically encode signed headers in the querystring\n  const signed = new Set(uploadUrl.searchParams.keys());\n  for (const h of Object.keys(headers)) {\n    if (!signed.has(h.toLowerCase())) throw new Error(`header ${h} not in signed set`);\n  }\n}","typeGuard":null,"tryCatchPattern":"for (let attempt = 0; attempt < 3; attempt++) {\n  try {\n    await directUploadPut(uploadUrl, bytes);\n    break;\n  } catch (err) {\n    if (/PUT failed: 40[03]/.test(String(err?.message)) && attempt < 2) {\n      uploadUrl = (await reserveUpload(id)).url;  // refresh\n      continue;\n    }\n    throw err;\n  }\n}","preventionTips":["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."],"tags":["cloud","upload","presigned-url","storage"],"backgroundTag":null,"analyzedSha":"c2996c8626135db5253519359d8a063d3bafad8d","analyzedAt":"2026-08-12T22:18:56.877Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}