snowflake-sdk
Version:
Node.js driver for Snowflake
633 lines • 29.9 kB
JavaScript
"use strict";
const fs = require('fs');
const EncryptionMetadata = require('./encrypt_util').EncryptionMetadata;
const FileHeader = require('../file_util').FileHeader;
const getProxyAgent = require('../http/node').getProxyAgent;
const ProxyUtil = require('../proxy_util');
const Util = require('../util');
const { lstrip } = require('../util');
const Logger = require('../logger').default;
const GlobalConfigTyped = require('../global_config_typed').default;
const { MULTIPART_THRESHOLD_BYTES, MULTIPART_PART_SIZE_BYTES, readChunk } = require('./multipart');
const GCS_METADATA_PREFIX = 'x-goog-meta-';
const SFC_DIGEST = 'sfc-digest';
const MATDESC_KEY = 'matdesc';
const ENCRYPTIONDATAPROP = 'encryptiondata';
const GCS_METADATA_SFC_DIGEST = GCS_METADATA_PREFIX + SFC_DIGEST;
const GCS_METADATA_MATDESC_KEY = GCS_METADATA_PREFIX + MATDESC_KEY;
const GCS_METADATA_ENCRYPTIONDATAPROP = GCS_METADATA_PREFIX + ENCRYPTIONDATAPROP;
const GCS_FILE_HEADER_DIGEST = 'gcs-file-header-digest';
const GCS_FILE_HEADER_CONTENT_LENGTH = 'gcs-file-header-content-length';
const GCS_FILE_HEADER_ENCRYPTION_METADATA = 'gcs-file-header-encryption-metadata';
const resultStatus = require('../file_util').resultStatus;
const EXPIRED_TOKEN = 'ExpiredToken';
const ERRORNO_WSAECONNABORTED = 10053; // network connection was aborted
/**
* How GCS PUT/GET works (read this first)
* =======================================
* A transfer happens in two phases:
* 1. Resolve - the driver sends the PUT/GET SQL to the Snowflake server (GS),
* which decides where the bytes go and how the client may write
* there, then returns a `stageInfo` (bucket, path prefix,
* credentials, and sometimes a presigned URL).
* 2. Transfer - the driver streams bytes straight to the GCS bucket using what
* GS returned. The driver never uploads to Snowflake directly.
*
* For GCS, GS returns exactly ONE of two credential styles per session (never a
* mix):
* (A) Downscoped access token (`stageInfo.creds.GCS_ACCESS_TOKEN`) - PREFERRED.
* "Downscoped" means folder/prefix-scoped, NOT object-scoped: one token
* authorizes every object under the staging path, so a single token covers
* all files in the PUT.
* (B) Presigned URL - FALLBACK/legacy. A signed URL authorizing one operation
* on exactly one object, so you need one presigned URL per file. These are
* minted by `updateFileMetasWithPresignedUrl` in file_transfer_agent.js,
* which re-resolves the PUT once per file (see the put-rank warning there).
*
* How each style authorizes a request at transfer time:
* - Token mode: PUT/GET `generateFileURL(stageInfo)` + file name, with an
* `Authorization: Bearer <GCS_ACCESS_TOKEN>` header.
* - Presigned mode: PUT/GET the presigned URL itself; no Authorization header.
* In token mode the URL and the token MUST come from the same resolution.
*
* Landmarks in this file: `createClient` is the mode switch; `uploadFile` picks
* the upload path; `applyGcsUploadError` maps HTTP failures onto the retry/renew
* states the outer loop understands (401 -> renew token, 400 -> renew presigned
* URL).
*/
/**
* Creates an GCS utility object.
* @param {module} connectionConfig
* @param {module} httpClient
* @param {module} fileStream
*
* @returns {Object}
* @constructor
*/
function GCSUtil(connectionConfig, httpClient) {
let axios = httpClient;
/**
* Build the per-session GCS client, which is really just the credential-style
* switch (see the module overview at the top of this file):
* - token mode: returns `{ gcsToken }` from `stageInfo.creds.GCS_ACCESS_TOKEN`
* - presigned mode: returns `null` (no token; each meta carries its own
* presigned URL instead)
* Also configures the HTTP client for the resolved GCS endpoint.
*
* @param {Object} stageInfo
*
* @returns {?{gcsToken: string}} token-bearing client, or null in presigned mode
*/
this.createClient = function (stageInfo) {
const stageCredentials = stageInfo['creds'];
const gcsToken = stageCredentials['GCS_ACCESS_TOKEN'];
// TODO: SNOW-1789759 the value is hardcoded now, but it should be server driven
const endPoint = this.getGCSCustomEndPoint(stageInfo);
const client = gcsToken ? { gcsToken } : null;
this.setupHttpClient(endPoint);
return client;
};
/**
* Extract the bucket name and path from the metadata's stage location.
*
* @param {String} stageLocation
*
* @returns {GCSLocation}
*/
this.extractBucketNameAndPath = function (stageLocation) {
let containerName = stageLocation;
let path = '';
// split stage location as bucket name and path
if (stageLocation.includes('/')) {
containerName = stageLocation.substring(0, stageLocation.indexOf('/'));
path = stageLocation.substring(stageLocation.indexOf('/') + 1, stageLocation.length);
if (path && !path.endsWith('/')) {
path += '/';
}
}
return GCSLocation(containerName, path);
};
/**
* Create file header based on file being uploaded or not.
*
* @param {Object} meta
* @param {String} filename
*
* @returns {Object}
*/
this.getFileHeader = async function (meta, filename) {
if (meta['resultStatus'] === resultStatus.UPLOADED ||
meta['resultStatus'] === resultStatus.DOWNLOADED) {
return FileHeader(meta[GCS_FILE_HEADER_DIGEST], meta[GCS_FILE_HEADER_CONTENT_LENGTH], meta[GCS_FILE_HEADER_ENCRYPTION_METADATA]);
}
else {
if (meta['presignedUrl']) {
await axios.get(meta['presignedUrl']).catch((err) => {
if ([401, 403, 404].includes(err.response.status)) {
meta['resultStatus'] = resultStatus.NOT_FOUND_FILE;
}
});
}
else {
const url = this.generateFileURL(meta.stageInfo, lstrip(filename, '/'));
const accessToken = meta['client'].gcsToken;
const gcsHeaders = { Authorization: `Bearer ${accessToken}` };
try {
const response = await axios.head(url, { headers: gcsHeaders });
const digest = response.headers[GCS_METADATA_SFC_DIGEST];
const contentLength = response.headers['content-length'];
const encryptionDataProp = response.headers[GCS_METADATA_ENCRYPTIONDATAPROP];
const matDescKey = response.headers[GCS_METADATA_MATDESC_KEY];
let encryptionMetadata;
if (encryptionDataProp) {
const encryptionData = JSON.parse(encryptionDataProp);
if (encryptionData) {
encryptionMetadata = EncryptionMetadata(encryptionData['WrappedContentKey']['EncryptedKey'], encryptionData['ContentEncryptionIV'], matDescKey ? matDescKey : null);
}
}
meta['resultStatus'] = resultStatus.UPLOADED;
return FileHeader(digest, contentLength, encryptionMetadata);
}
catch (err) {
const errCode = !isNaN(err['code']) && !isNaN(parseInt(err['code']))
? err['code']
: err.response.status;
if ([403, 408, 429, 500, 503].includes(errCode)) {
meta['lastError'] = err;
meta['resultStatus'] = resultStatus.NEED_RETRY;
return;
}
if (errCode === 404) {
meta['resultStatus'] = resultStatus.NOT_FOUND_FILE;
}
else if (errCode === 401) {
meta['lastError'] = err;
meta['resultStatus'] = resultStatus.RENEW_TOKEN;
}
else {
meta['lastError'] = err;
meta['resultStatus'] = resultStatus.ERROR;
throw err;
}
}
}
}
return FileHeader(null, null, null);
};
/**
* Read the file's stat, then dispatch to one of two upload paths. Which path
* applies depends on the credential style (token vs presigned; see the module
* overview) and the file size:
*
* 1. **Single Buffer-bodied PUT** — when the session uses a legacy
* presigned URL or the file is no larger than
* `MULTIPART_THRESHOLD_BYTES`. Workspace stage presigned URLs are
* signed for one specific PUT and cannot initiate a resumable upload.
* So when GS hands back a `presignedUrl` (the legacy path;
* pre-CB_2023_06 deployments or driver versions before 1.6.21), large
* files fall back to a single Buffer PUT even though the access-token
* path would split into chunks.
* 2. **XML API resumable upload session** — when an access token is
* available and the file exceeds `MULTIPART_THRESHOLD_BYTES`. Bounds
* in-flight memory by `MULTIPART_PART_SIZE_BYTES` and gives per-chunk
* retry granularity. See `uploadFileResumable` for protocol
* details.
*
* @param {String} dataFile
* @param {Object} meta
* @param {Object} encryptionMetadata
* @param {Number} maxConcurrency
*/
this.uploadFile = async function (dataFile, meta, encryptionMetadata, maxConcurrency) {
const fileSize = (await fs.promises.stat(dataFile)).size;
meta['uploadFileSize'] = fileSize;
const accessToken = meta['client'] && meta['client'].gcsToken;
const usingPresignedUrl = !!meta['presignedUrl'];
const canResumable = !!accessToken && !usingPresignedUrl;
if (GlobalConfigTyped.getValue('enableExperimentalMultipartUploads')) {
if (canResumable && fileSize > MULTIPART_THRESHOLD_BYTES) {
await this.uploadFileResumable(dataFile, fileSize, meta, encryptionMetadata);
}
else {
// TODO: temporarily use readFile instead of createReadStream to work around a Bun bug
// where createReadStream fails the upload in compiled apps. Investigate the root cause
// and fix it properly.
const buffer = await fs.promises.readFile(dataFile);
await this.uploadFileStream(buffer, meta, encryptionMetadata, maxConcurrency);
}
}
else {
const fileStream = fs.createReadStream(dataFile);
await this.uploadFileStream(fileStream, meta, encryptionMetadata, maxConcurrency);
}
};
/**
* GCS resumable upload session for `dataFile` (access-token mode only;
* presigned URLs cannot initiate a resumable session). Reads
* `MULTIPART_PART_SIZE_BYTES` bytes per chunk into a fresh Buffer and PUTs
* each as a `Content-Range`-tagged chunk against the session URL minted by
* the initiation POST. On transient chunk failure, queries the session for
* the committed offset and resumes from there. On terminal failure,
* best-effort DELETE the session so the upload doesn't linger as a
* half-staged blob.
*
* Uses GCS's XML API resumable variant (initiate POST against the same
* bucket-path URL the single-PUT uses, with `x-goog-resumable: start`)
* rather than JSON API resumable, since GS hands the connector that XML
* shape; honoring it avoids inventing a path prefix that the server
* never returned and keeps strict-allowlisting environments working.
*
* @param {String} dataFile
* @param {Number} fileSize
* @param {Object} meta
* @param {Object} encryptionMetadata
*/
this.uploadFileResumable = async function (dataFile, fileSize, meta, encryptionMetadata) {
const accessToken = meta['client'].gcsToken;
const stageInfo = meta.stageInfo;
const tempFilename = meta['dstFileName'].substring(meta['dstFileName'].indexOf('/') + 1, meta['dstFileName'].length);
const initUrl = this.generateFileURL(stageInfo, tempFilename);
const contentEncoding = normalizeGcsContentEncoding(meta);
const initHeaders = {
Authorization: `Bearer ${accessToken}`,
'Content-Length': 0,
'Content-Type': 'application/octet-stream',
...(contentEncoding ? { 'Content-Encoding': contentEncoding } : {}),
'x-goog-resumable': 'start',
'x-upload-content-length': fileSize,
...buildGcsEncryptionHeaders(meta, encryptionMetadata),
};
let sessionUrl;
try {
const initResp = await axios.post(initUrl, '', { headers: initHeaders });
sessionUrl = initResp.headers?.location;
if (!sessionUrl) {
throw new Error('GCS resumable initiate succeeded without a Location header');
}
const fd = await fs.promises.open(dataFile, 'r');
try {
let position = 0;
while (position < fileSize) {
const thisChunkSize = Math.min(MULTIPART_PART_SIZE_BYTES, fileSize - position);
const buf = await readChunk(fd, position, thisChunkSize);
const start = position;
const end = position + thisChunkSize - 1;
const chunkResp = await axios.put(sessionUrl, buf, {
maxBodyLength: Infinity,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Length': buf.length,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
},
// GCS resumable uploads use 308 ("Resume Incomplete") as a normal
// "keep going" signal, so treat it as success alongside 200/201 and
// dispatch on the status below rather than letting axios throw.
validateStatus: (s) => s === 200 || s === 201 || s === 308,
});
if (chunkResp.status === 308) {
// GCS reports the last committed byte in the Range header (e.g.
// "bytes=0-262143" means the next write starts at 262144). A missing
// or malformed header means we can't trust the committed offset, so we
// throw rather than risk skipping un-persisted bytes and corrupting the
// object.
const commitedRangeStr = chunkResp.headers?.range;
const parsedRange = commitedRangeStr && /^bytes=(\d+)-(\d+)$/.exec(commitedRangeStr);
if (!parsedRange) {
throw new Error(`GCS resumable 308 returned no usable Range header (got "${commitedRangeStr}"); cannot safely resume`);
}
position = parseInt(parsedRange[2], 10) + 1;
}
else {
// Last chunk is 200OK with no range header
position = fileSize;
}
}
}
finally {
await fd.close();
}
meta['dstFileSize'] = meta['uploadSize'];
meta['resultStatus'] = resultStatus.UPLOADED;
meta[GCS_FILE_HEADER_DIGEST] = initHeaders[GCS_METADATA_SFC_DIGEST];
meta[GCS_FILE_HEADER_CONTENT_LENGTH] = meta['uploadSize'];
meta[GCS_FILE_HEADER_ENCRYPTION_METADATA] = initHeaders[GCS_METADATA_ENCRYPTIONDATAPROP];
}
catch (err) {
// Best-effort cleanup
if (sessionUrl) {
try {
await axios.delete(sessionUrl, { headers: { Authorization: `Bearer ${accessToken}` } });
}
catch (_cleanupErr) {
// Suppress.
}
}
applyGcsUploadError(err, meta, /* hasAccessToken */ true);
}
};
/**
* Single-PUT upload used by both credential styles (and the small-file /
* legacy fallback when a resumable session isn't used). Picks the destination
* the same way every GCS request does:
* - presigned mode: PUT to `meta.presignedUrl` (no Authorization header)
* - token mode: PUT to `generateFileURL(meta.stageInfo)` with a Bearer
* token from `meta.client.gcsToken`
* In token mode the URL (from `meta.stageInfo`) and the token (from
* `meta.client`) must originate from the same resolution; see the put-rank
* warning in `updateFileMetasWithPresignedUrl` for why mixing resolutions
* yields a 403.
*
* @param {Buffer|string|stream.Readable} fileStream
* @param {Object} meta
* @param {Object} encryptionMetadata
*
* @returns {null}
*/
this.uploadFileStream = async function (fileStream, meta, encryptionMetadata) {
let uploadUrl = meta['presignedUrl'];
let accessToken = null;
if (!uploadUrl) {
const tempFilename = meta['dstFileName'].substring(meta['dstFileName'].indexOf('/') + 1, meta['dstFileName'].length);
uploadUrl = this.generateFileURL(meta.stageInfo, tempFilename);
accessToken = meta['client'].gcsToken;
}
const gcsHeaders = {
'Content-Encoding': normalizeGcsContentEncoding(meta),
...buildGcsEncryptionHeaders(meta, encryptionMetadata),
};
if (accessToken) {
gcsHeaders['Authorization'] = `Bearer ${accessToken}`;
}
try {
if (Buffer.isBuffer(fileStream)) {
gcsHeaders['Content-Length'] = fileStream.length;
}
else if (typeof fileStream === 'string') {
gcsHeaders['Content-Length'] = Buffer.byteLength(fileStream);
}
else if (meta && typeof meta['uploadFileSize'] === 'number') {
gcsHeaders['Content-Length'] = meta['uploadFileSize'];
}
await axios.put(uploadUrl, fileStream, { maxBodyLength: Infinity, headers: gcsHeaders });
}
catch (err) {
applyGcsUploadError(err, meta, !!accessToken);
return;
}
meta['dstFileSize'] = meta['uploadSize'];
meta['resultStatus'] = resultStatus.UPLOADED;
meta[GCS_FILE_HEADER_DIGEST] = gcsHeaders[GCS_METADATA_SFC_DIGEST];
meta[GCS_FILE_HEADER_CONTENT_LENGTH] = meta['uploadSize'];
meta[GCS_FILE_HEADER_ENCRYPTION_METADATA] = gcsHeaders[GCS_METADATA_ENCRYPTIONDATAPROP];
};
/**
* Download the file.
*
* @param {Object} meta
* @param fullDstPath
*
* @returns {null}
*/
this.nativeDownloadFile = async function (meta, fullDstPath) {
let downloadUrl = meta['presignedUrl'];
let accessToken = null;
let gcsHeaders = {};
if (!downloadUrl) {
downloadUrl = this.generateFileURL(meta.stageInfo, lstrip(meta['srcFileName'], '/'));
accessToken = meta['client'].gcsToken;
gcsHeaders = { Authorization: `Bearer ${accessToken}` };
}
let encryptionDataprop;
let matDescKey;
let sfcDigest;
let size;
try {
Logger().debug(`Downloading file from GCS using Axios`);
let response;
await axios
.get(downloadUrl, {
headers: gcsHeaders,
responseType: 'stream',
})
.then(async (res) => {
response = res;
await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(fullDstPath);
response.data.pipe(writer);
writer.on('error', (err) => {
writer.close();
reject(err);
});
writer.on('close', () => {
resolve();
});
});
});
Logger().debug(`Sent Get Request to ${downloadUrl}, destination: ${fullDstPath}, http status: ${response.status}`);
encryptionDataprop = response.headers[GCS_METADATA_ENCRYPTIONDATAPROP];
matDescKey = response.headers[GCS_METADATA_MATDESC_KEY];
sfcDigest = response.headers[GCS_METADATA_SFC_DIGEST];
size = response.headers['content-length'];
}
catch (err) {
if (err['code'] === EXPIRED_TOKEN) {
meta['resultStatus'] = resultStatus.RENEW_TOKEN;
}
else {
meta['lastError'] = err;
if (err['code'] === ERRORNO_WSAECONNABORTED) {
meta['resultStatus'] = resultStatus.NEED_RETRY_WITH_LOWER_CONCURRENCY;
}
else {
meta['resultStatus'] = resultStatus.NEED_RETRY;
}
}
return;
}
let encryptionData;
if (encryptionDataprop) {
encryptionData = JSON.parse(encryptionDataprop);
}
let encryptionMetadata;
if (encryptionData) {
encryptionMetadata = EncryptionMetadata(encryptionData['WrappedContentKey']['EncryptedKey'], encryptionData['ContentEncryptionIV'], matDescKey);
}
const fileInfo = fs.statSync(fullDstPath);
meta['srcFileSize'] = fileInfo.size;
meta['resultStatus'] = resultStatus.DOWNLOADED;
meta[GCS_FILE_HEADER_DIGEST] = sfcDigest;
meta[GCS_FILE_HEADER_CONTENT_LENGTH] = size;
meta[GCS_FILE_HEADER_ENCRYPTION_METADATA] = encryptionMetadata;
};
/**
* Generate file URL based on bucket.
*
* @param {Object} stageInfo
* @param {String} filename
*
* @returns {String}
*/
this.generateFileURL = function (stageInfo, filename) {
const gcsLocation = this.extractBucketNameAndPath(stageInfo.location);
const fullFilePath = `${gcsLocation.path}${filename}`;
const endPoint = this.getGCSCustomEndPoint(stageInfo);
let link;
if (stageInfo['useVirtualUrl']) {
link = `${endPoint}/${fullFilePath}`;
}
else {
link = `${endPoint != null ? endPoint : 'https://storage.googleapis.com'}/${gcsLocation.bucketName}/${fullFilePath}`;
}
// Default to https:// only when no scheme is present, so an explicit http://
// (e.g. a local mock) is kept. Matches Universal Driver code.
return link.startsWith('http://') || link.startsWith('https://') ? link : `https://${link}`;
};
this.getGCSCustomEndPoint = function (stageInfo) {
//TODO: SNOW-1789759 hardcoded region will be replaced in the future
const isRegionalUrlEnabled = stageInfo.region.toLowerCase() === 'me-central2' || stageInfo.useRegionalUrl;
let endPoint = null;
if (stageInfo['endPoint']) {
endPoint = stageInfo['endPoint'];
}
else if (stageInfo['useVirtualUrl']) {
const bucket = this.extractBucketNameAndPath(stageInfo.location).bucketName;
endPoint = `https://${bucket}.storage.googleapis.com`;
}
else if (isRegionalUrlEnabled) {
endPoint = `storage.${stageInfo.region.toLowerCase()}.rep.googleapis.com`;
}
return endPoint;
};
this.setupHttpClient = function (endPoint) {
if (typeof httpClient === 'undefined') {
const proxy = ProxyUtil.getProxy(connectionConfig.getProxy(), 'GCS Util');
if (proxy || Util.getEnvVar('http_proxy')) {
const proxyAgent = getProxyAgent({
proxyOptions: proxy,
connectionConfig,
parsedUrl: new URL(connectionConfig.accessUrl),
destination: endPoint || 'storage.googleapis.com',
});
// TODO: This axios should handle and retry network failures (and
// transient 5xx/408/429 responses) similarly to lib/http/axios_instance
// and its useSnowflakeRetryMiddleware retry/backoff interceptors, so GCS
// transfers behave consistently with the rest of the connector.
//
// It is not a drop-in swap; there are open issues we still need to
// figure out before adopting the shared instance here:
// 1. Proxy: the shared instance has no agent of its own, while this
// path needs a GCS-destination-specific proxy agent.
// 2. Non-replayable bodies: the retry interceptor re-issues the
// request, but uploadFileStream PUTs a one-shot fs.ReadStream and
// nativeDownloadFile uses responseType:'stream', which cannot be
// safely replayed.
// 3. Double-retry: remote_storage_util.js already retries off
// meta.resultStatus (and applyGcsUploadError maps transient codes
// to NEED_RETRY), so adding the middleware risks compounding
// retries/backoff.
// 4. Token/URL renewal: 401->RENEW_TOKEN and 400->RENEW_PRESIGNED_URL
// are handled by the outer loop and must not be swallowed by a
// generic retry layer.
axios = require('axios').create({
proxy: false,
httpAgent: proxyAgent,
httpsAgent: proxyAgent,
});
}
else {
axios = require('axios');
}
}
};
}
/**
* Resolve the Content-Encoding for a GCS upload from the meta's destination
* compression type, blanking it for the self-describing types (which GCS
* would otherwise try to decode).
*/
function normalizeGcsContentEncoding(meta) {
let contentEncoding = '';
if (meta['dstCompressionType']) {
contentEncoding = String(meta['dstCompressionType']['name']).toLowerCase();
}
if (['gzip', 'bzip2', 'brotli', 'deflate', 'raw_deflate', 'zstd'].includes(contentEncoding)) {
contentEncoding = '';
}
return contentEncoding;
}
/**
* Build the sfc-digest + encryption envelope headers (`x-goog-meta-*`) so
* every upload path persists the same metadata shape.
*/
function buildGcsEncryptionHeaders(meta, encryptionMetadata) {
const headers = { [GCS_METADATA_SFC_DIGEST]: meta['SHA256_DIGEST'] };
if (encryptionMetadata) {
headers[GCS_METADATA_ENCRYPTIONDATAPROP] = JSON.stringify({
EncryptionMode: 'FullBlob',
WrappedContentKey: {
KeyId: 'symmKey1',
EncryptedKey: encryptionMetadata.key,
Algorithm: 'AES_CBC_256',
},
EncryptionAgent: {
Protocol: '1.0',
EncryptionAlgorithm: 'AES_CBC_256',
},
ContentEncryptionIV: encryptionMetadata.iv,
KeyWrappingMetadata: {
EncryptionLibrary: 'Java 5.3.0',
},
});
headers[GCS_METADATA_MATDESC_KEY] = encryptionMetadata.matDesc;
}
return headers;
}
/**
* @typedef {object} GCSLocation
* @property {string} bucketName
* @property {string} path
*/
function GCSLocation(bucketName, path) {
return {
bucketName: bucketName,
path: path,
};
}
/**
* Translate a GCS error into the file-transfer-agent's `meta.resultStatus`
* convention so retry semantics stay consistent across every upload path.
*
* `hasAccessToken` distinguishes the access-token path (renew the token on
* 401) from the legacy presigned-URL path (renew the URL on 400).
*
* Note on 403: a persistent 403 here is usually deterministic — an
* auth-boundary/path mismatch (e.g. a token scoped to a different put-rank
* folder than the upload target; see `updateFileMetasWithPresignedUrl`) — not a
* transient failure. It is still mapped to NEED_RETRY as a best effort for the
* genuinely transient 403s, but retries won't fix a boundary mismatch.
*/
function applyGcsUploadError(err, meta, hasAccessToken) {
// Capture prior `lastError.code` before overwriting `meta['lastError']`:
// the legacy presigned-URL renew branch suppresses a second consecutive
// 400 to avoid a renew loop, and that check needs to see the *previous*
// attempt's code, not the one we're about to record.
const priorCode = meta['lastError'] && meta['lastError']['code'];
meta['lastError'] = err;
const code = err && err['code'];
const status = err && err['response'] && err['response']['status'];
const httpCode = code || status;
if ([403, 408, 429, 500, 502, 503, 504].includes(httpCode)) {
meta['resultStatus'] = resultStatus.NEED_RETRY;
}
else if (hasAccessToken && httpCode === 401) {
meta['resultStatus'] = resultStatus.RENEW_TOKEN;
}
else if (!hasAccessToken && httpCode === 400 && priorCode !== 400) {
meta['resultStatus'] = resultStatus.RENEW_PRESIGNED_URL;
}
else {
meta['resultStatus'] = resultStatus.NEED_RETRY;
}
}
module.exports = GCSUtil;
//# sourceMappingURL=gcs_util.js.map