tinyhumansai/openhuman · error · Error

asset upload failed: ${res.status} ${res.statusText} ${await

Error message

asset upload failed: ${res.status} ${res.statusText} ${await res.text()}

What it means

uploadOperation() executes one reserved upload operation: it slices fileBuffer at [offset, offset+length] and PUTs the chunk to the signed Apple object-store URL with the exact requestHeaders ASC returned. A non-ok PUT response throws with status, statusText and the raw body — unlike error 268 this comes from the storage backend, not the ASC JSON API.

Source

Thrown at scripts/ios-appstore-metadata.mjs:100

    throw new Error(`${method} ${resourcePath} failed: ${message}`);
  }
  return payload;
}

async function uploadOperation(operation, fileBuffer) {
  const headers = Object.fromEntries(
    (operation.requestHeaders || []).map((h) => [h.name, h.value]),
  );
  const offset = Number(operation.offset || 0);
  const length = Number(operation.length || fileBuffer.length);
  const chunk = fileBuffer.subarray(offset, offset + length);
  const res = await fetch(operation.url, {
    method: operation.method,
    headers,
    body: chunk,
  });
  if (!res.ok) {
    throw new Error(
      `asset upload failed: ${res.status} ${res.statusText} ${await res.text()}`,
    );
  }
}

async function textFile(name) {
  return (await readFile(path.join(metadataDir, name), "utf8")).trim();
}

async function firstPage(resourcePath) {
  const payload = await request("GET", resourcePath);
  return payload.data || [];
}

async function getOrCreateAppInfoLocalization() {
  const appInfos = await firstPage(`/apps/${appId}/appInfos?limit=10`);
  if (!appInfos.length) {
    throw new Error(`No appInfos found for app ${appId}.`);

View on GitHub (pinned to a221052e0d)

Solutions

  1. Re-run the whole script end-to-end so fresh upload operations are reserved and used within the same run — do not resume a half-finished run with changed files
  2. Keep fastlane/screenshots/en-US untouched while the script runs (it deletes and recreates the screenshot set each run, so a clean re-run is safe)
  3. On persistent 403 with 'Request has expired' in the body, check system clock sync, then re-run
  4. If the buffer/length mismatch repeats, verify the PNGs are fully written (not mid-copy) before starting

Example fix

# before — partial run, then files changed:
node scripts/ios-appstore-metadata.mjs   # fails midway
# (regenerate screenshots here)
node scripts/ios-appstore-metadata.mjs   # upload fails: stale ops

# after — single clean run over stable files:
# (finish all screenshot edits first)
node scripts/ios-appstore-metadata.mjs
Defensive patterns

Strategy: retry

Validate before calling

// Guard before each PUT: operation must fit the current buffer
const ok = operation.offset + operation.length <= fileBuffer.length;
if (!ok) { console.error('Stale upload operations vs. file size — restart the run'); process.exit(1); }

Try / catch

async function uploadWithRetry(operation, buf, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try { return await uploadOperation(operation, buf); }
    catch (err) {
      const transient = /\b(5\d\d|429)\b/.test(err.message);
      if (!transient || i === attempts - 1) throw err;
      await new Promise(r => setTimeout(r, 2 ** i * 1000));
    }
  }
}

Prevention

When it happens

Trigger: Signed upload operations expiring because a long time passed between createScreenshotSet() and the PUT; the local PNG file being regenerated/changed between reserve and upload so Content-Length no longer matches; offset/length from the reserve response exceeding the file's size; transient 5xx from the CDN.

Common situations: Re-running after a partial failure but with new screenshots on disk; large screenshot sets over slow links where later operations go stale; system clock skew invalidating signed URLs.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16). Data as JSON: /api/errors/0912928f5c1f49f7. Report an issue: GitHub.