Skip to content
Skip to chapter

Chapter 10

Reliable automations

Fetch data from web APIs

Use UrlFetchApp.fetch() in Google Apps Script to import JSON from a web API into a Google Sheet range and handle paginated HTTP responses and retries.

By the end

Build and inspect real HTTP requests, validate their responses before using them, and stop a paginated read without misrepresenting partial data as complete.

Chapter navigation

Bring outside data into a bounded calculation

Import seven days of public weather data into a reserved Sheets range, then read a bounded number of pages from GitHub's public REST API and report when the result is a partial sample. These are two separate projects: the weather script writes to a spreadsheet, while the pagination script logs public repository metadata.

Whereas previous chapters read and wrote Google Sheets ranges, HTTP requests cross an external boundary: a remote server controls the response, can reject requests or enforce rate limits, and can change its data between runs. Validate every HTTP response before writing its values to a sheet.

Describe the request before sending it

An HTTP request is a message sent to a server to read or modify a resource. Apps Script sends HTTP requests through UrlFetchApp rather than the browser fetch API. Use built-in or advanced Google services when calling supported Google APIs, and UrlFetchApp for external web APIs.

Each request has five main parts:

PartExampleMeaning
MethodGETReads a resource without modifying remote state.
URLhttps://api.open-meteo.com/v1/forecastThe HTTPS scheme, host, and endpoint path.
Querylatitude=52.52&longitude=13.41Key-value parameters after ?, separated by &.
HeadersAccept: application/jsonRequest metadata; Accept requests JSON from the server.
BodyNone in these GET requestsPayload sent with write methods such as POST.

An endpoint is the specific URL path and HTTP method an API exposes. HTTPS encrypts the request in transit, while the destination server still receives and may log the full URL, query parameters, and headers.

When building a query string from variables, encode each parameter value with encodeURIComponent rather than encoding the entire URL, so special characters such as & inside a value cannot inject extra parameters.

Preview a request without contacting its URL

UrlFetchApp.getRequest() returns the request object that UrlFetchApp.fetch() would send, without opening a network connection.

Code.gs
function previewRequest() {
  const baseUrl = 'https://example.com/api/requests';
  const query = 'status=open&limit=10';
  const request = UrlFetchApp.getRequest(baseUrl + '?' + query, {
    headers: { Accept: 'application/json' },
    method: 'get',
  });

  Logger.log(JSON.stringify(request, null, 2));
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request"
  ]
}

Download appsscript.json
sample-request.json
{
  "baseUrl": "https://example.com/api/requests",
  "query": {
    "status": "open",
    "limit": 10
  },
  "headers": {
    "Accept": "application/json"
  }
}

Download sample-request.json
  1. Create a new standalone project at script.google.com and replace Code.gs with the preview file.

  2. In Project Settings, enable Show "appsscript.json" manifest file in editor and replace appsscript.json with the included manifest (sample-request.json is a reference file and is not uploaded to Apps Script).

  3. Select previewRequest, save, and click Run. The manifest declares script.external_request because getRequest requires that scope even though it makes no network call.

  4. Inspect Execution log for the formatted JSON object showing the GET URL https://example.com/api/requests?status=open&limit=10 and the Accept header.

  5. Change status=open to status=resolved in query, save, and rerun to see the updated URL in the log, then restore status=open.

Both live projects declare https://www.googleapis.com/auth/script.external_request in appsscript.json (scope guide). That Google OAuth scope allows Apps Script to make outbound HTTP requests; it does not authenticate you to the remote API. The weather project also declares https://www.googleapis.com/auth/spreadsheets.currentonly to write to its bound sheet.

Remote API authentication (such as an API key or Authorization header) is separate from Google OAuth consent. Neither Open-Meteo's free forecast endpoint nor GitHub's public tags endpoint requires credentials, and you should never send a Google OAuth token to a third-party API.

Outbound requests originate from Google's URL Fetch IP pool rather than your browser's IP address (UrlFetchApp reference).

Check status, content and data as separate steps

HTTPResponse provides getResponseCode() for the numeric HTTP status, getAllHeaders() for the response headers, and getContentText('UTF-8') for the response body text.

An HTTP error status (such as 404 or 503) is a returned response you can inspect, whereas a DNS failure, TLS error, missing Google scope, or exhausted Apps Script quota throws an exception before any response arrives.

By default, UrlFetchApp.fetch throws on HTTP 4xx and 5xx statuses. Both projects pass muteHttpExceptions: true so they can inspect getResponseCode() directly and require status 200, and both pass followRedirects: false so an unexpected 3xx redirect halts the run.

Before parsing the body, the helpers check the response's Content-Type header case-insensitively (ignoring parameters such as charset=utf-8), because proxies and gateways often return HTML error pages even when the request sent Accept: application/json.

JSON.parse converts a JSON string into a JavaScript value, and try/catch catches syntax errors if the body is malformed. Because valid JSON can still have missing arrays or non-numeric fields, each project validates the parsed structure and field types before using the data.

Use Open-Meteo within its free-service terms

The forecast project requests daily high and low temperatures for Berlin (latitude=52.52, longitude=13.41) from Open-Meteo's forecast API, passing timezone=auto for local calendar dates and temperature_unit=celsius.

Open-Meteo's free tier currently limits usage to fewer than 10,000 calls per day, 5,000 per hour, and 600 per minute, and its CC BY 4.0 licence requires attribution, a licence link, and a note indicating modifications. The script writes those three attribution rows directly below the forecast table in A9:B11.

The GET request sends the coordinates and query parameters in the URL; Open-Meteo's privacy policy notes that API logs may record requested coordinates, so keep the public Berlin coordinates for this exercise.

Reserve the forecast destination before the first run

Create a disposable spreadsheet where the script manages Forecast!A1:C12:

Code.gs
const FORECAST_DAYS = 7;
const FORECAST_LATITUDE = 52.52;
const FORECAST_LONGITUDE = 13.41;
const FORECAST_OWNER_MARKER = 'Owned by weather-import';

/**
 * A property-access view for validation, not a validated provider response.
 * Every data field remains unknown until the checks in forecastRows succeed.
 * @typedef {{daily?: {time?: unknown, temperature_2m_max?: unknown, temperature_2m_min?: unknown}|null, daily_units?: {temperature_2m_max?: unknown, temperature_2m_min?: unknown}|null}} ForecastCandidate
 */

function importForecastToSheet() {
  if (!Number.isInteger(FORECAST_DAYS) || FORECAST_DAYS < 1 || FORECAST_DAYS > 7) {
    throw new Error('Set FORECAST_DAYS to a whole number from 1 to 7.');
  }
  if (!Number.isFinite(FORECAST_LATITUDE) || Math.abs(FORECAST_LATITUDE) > 90 ||
      !Number.isFinite(FORECAST_LONGITUDE) || Math.abs(FORECAST_LONGITUDE) > 180) {
    throw new Error('Use numeric latitude from -90 to 90 and longitude from -180 to 180.');
  }

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) {
    throw new Error('Open this project through Extensions > Apps Script in the practice spreadsheet.');
  }
  const sheet = spreadsheet.getSheetByName('Forecast');
  if (!sheet) {
    throw new Error('Create a blank tab named Forecast before running this function.');
  }
  const target = sheet.getRange('A1:C12');
  checkForecastDestination(target, sheet);

  const query = [
    'latitude=' + encodeURIComponent(FORECAST_LATITUDE),
    'longitude=' + encodeURIComponent(FORECAST_LONGITUDE),
    'daily=temperature_2m_max,temperature_2m_min',
    'temperature_unit=celsius',
    'timezone=auto',
    'forecast_days=' + FORECAST_DAYS
  ].join('&');
  const response = UrlFetchApp.fetch('https://api.open-meteo.com/v1/forecast?' + query, {
    method: 'get',
    headers: { Accept: 'application/json' },
    muteHttpExceptions: true,
    followRedirects: false
  });
  const statusCode = response.getResponseCode();
  if (statusCode !== 200) {
    throw new Error('The forecast service answered with status code ' + statusCode + '. No cells were written.');
  }
  const contentType = forecastHeader(response.getAllHeaders(), 'content-type');
  if (contentType.split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new Error('The forecast response was not application/json. No cells were written.');
  }
  const text = response.getContentText('UTF-8');
  if (text.length > 50000) {
    throw new Error('The forecast response exceeded the 50000-character parsing limit.');
  }
  /** @type {unknown} */
  let forecast;
  try {
    forecast = JSON.parse(text);
  } catch (error) {
    throw new Error('The forecast response was not valid JSON. No cells were written.');
  }

  const rows = forecastRows(forecast, FORECAST_DAYS);
  // Check again after the request in case the destination changed while it was pending.
  checkForecastDestination(target, sheet);
  target.setValues(rows);
  Logger.log('Wrote ' + FORECAST_DAYS + ' days of forecast.');
}

/**
 * @param {SpreadsheetApp.Range} target
 * @param {SpreadsheetApp.Sheet} sheet
 * @returns {void}
 */
function checkForecastDestination(target, sheet) {
  if (target.isPartOfMerge()) {
    throw new Error('Forecast!A1:C12 must not overlap merged cells.');
  }
  if (target.isBlank()) return;
  const markerCell = sheet.getRange('A12');
  if (markerCell.getValue() !== FORECAST_OWNER_MARKER || markerCell.getFormula() !== '') {
    throw new Error('Forecast!A1:C12 is not blank and has no valid ownership marker. Use a new practice sheet; do not clear existing data to bypass this check.');
  }
}

/**
 * @param {unknown} rawForecast
 * @param {number} expectedDays
 * @returns {Array<Array<string|number>>}
 */
function forecastRows(rawForecast, expectedDays) {
  // This view permits property probes; the original runtime checks still validate the value.
  const forecast = /** @type {ForecastCandidate|null|undefined} */ (rawForecast);
  if (!forecast || typeof forecast !== 'object' || Array.isArray(forecast) ||
      !forecast.daily || !forecast.daily_units) {
    throw new Error('The forecast response is missing daily data or units.');
  }
  const days = forecast.daily.time;
  const highs = forecast.daily.temperature_2m_max;
  const lows = forecast.daily.temperature_2m_min;
  if (!Array.isArray(days) || !Array.isArray(highs) || !Array.isArray(lows) ||
      days.length !== expectedDays || highs.length !== expectedDays || lows.length !== expectedDays) {
    throw new Error('The forecast arrays must each contain exactly ' + expectedDays + ' days.');
  }
  if (forecast.daily_units.temperature_2m_max !== '°C' ||
      forecast.daily_units.temperature_2m_min !== '°C') {
    throw new Error('The forecast temperature units must be Celsius.');
  }

  /** @type {Array<Array<string|number>>} */
  const rows = [['Date', 'High', 'Low']];
  let index = 0;
  /** @type {number|null} */
  let previousDay = null;
  while (index < expectedDays) {
    /** @type {unknown} */
    const day = days[index];
    /** @type {unknown} */
    const high = highs[index];
    /** @type {unknown} */
    const low = lows[index];
    if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) {
      throw new Error('Forecast day ' + (index + 1) + ' is not a YYYY-MM-DD date.');
    }
    const timestamp = Date.parse(day + 'T00:00:00Z');
    if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== day ||
        (previousDay !== null && timestamp - previousDay !== 86400000)) {
      throw new Error('Forecast dates must be valid consecutive calendar days.');
    }
    if (!Number.isFinite(high) || !Number.isFinite(low) ||
        /** @type {number} */ (high) < /** @type {number} */ (low)) {
      throw new Error('Forecast day ' + (index + 1) + ' needs numeric high and low temperatures with high >= low.');
    }
    rows.push([day, /** @type {number} */ (high), /** @type {number} */ (low)]);
    previousDay = timestamp;
    index = index + 1;
  }
  // The fixed output area has room for seven days, even when fewer are requested.
  while (rows.length < 8) {
    rows.push(['', '', '']);
  }
  rows.push(['Weather data by Open-Meteo', 'https://open-meteo.com/', '']);
  rows.push(['Licence: CC BY 4.0', 'https://creativecommons.org/licenses/by/4.0/', '']);
  rows.push(['Selected daily highs/lows; Celsius; local dates', '', '']);
  rows.push([FORECAST_OWNER_MARKER, '', '']);
  return rows;
}

/**
 * @param {Record<string, string|string[]>} headers
 * @param {string} wantedName
 * @returns {string}
 */
function forecastHeader(headers, wantedName) {
  for (const name of Object.keys(headers)) {
    if (name.toLowerCase() === wantedName) {
      const value = headers[name];
      if (Array.isArray(value)) return value.join(', ');
      return String(value);
    }
  }
  return '';
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/spreadsheets.currentonly"
  ]
}

Download appsscript.json
  1. Create a new spreadsheet named Forecast practice, rename its tab Forecast, and leave A1:C12 blank and unmerged.

  2. Open Extensions > Apps Script and replace Code.gs with the complete weather-import file.

  3. In Project Settings, enable Show "appsscript.json" manifest file in editor, replace appsscript.json with the included manifest, and save both files.

  4. Keep FORECAST_DAYS = 7, FORECAST_LATITUDE = 52.52, and FORECAST_LONGITUDE = 13.41.

  5. Select importForecastToSheet, click Run, and approve the current-spreadsheet and external-request permissions.

  6. Inspect Forecast!A1:C12 and Execution log when the run completes.

Before sending the HTTP request and again before calling setValues(), the script verifies that Forecast exists, A1:C12 overlaps no merged cells, and A1:C12 is either completely blank or already carries the ownership marker Owned by weather-import in A12 (Range reference). Once A12 holds the marker, subsequent runs overwrite A1:C12.

Recognize the live result and the write boundary

With a valid seven-day response, Forecast!A1:C12 contains:

CellsExpected contents
A1:C1Date, High, Low
A2:C8Seven consecutive local-date labels with numeric daily high and low temperatures in Celsius
A9:B9Open-Meteo credit and source URL
A10:B10CC BY 4.0 label and licence URL
A11Selection, units and local-date description
A12Owned by weather-import

The execution log displays:

Source
Wrote 7 days of forecast.

Because the endpoint returns live model data, temperatures and dates change over time, and the returned grid coordinates may reflect the nearest weather-model grid point.

Before calling setValues(), the script verifies the 200 status, JSON content type, a 50,000-character body limit, array lengths matching FORECAST_DAYS, Celsius units, consecutive valid YYYY-MM-DD date strings, and finite numeric high/low temperatures with high >= low.

The output array is always 12 rows by 3 columns. Requesting fewer than seven days pads the remaining rows through row 8 with '', clearing stale rows from earlier runs in the same setValues() call. Because date labels are validated as YYYY-MM-DD strings and temperatures as finite numbers, remote strings starting with = cannot be written as formulas.

Read the validation helpers

validateDailyForecast checks both date syntax and calendar validity:

  1. The regular expression /^\d{4}-\d{2}-\d{2}$/ requires a four-digit year, two-digit month, and two-digit day separated by hyphens.

  2. Date.parse(day + 'T00:00:00Z') parses the label at UTC midnight, and new Date(timestamp).toISOString().slice(0, 10) === day confirms the date did not roll over from an invalid calendar day such as 2026-02-30.

  3. Each UTC midnight timestamp must exceed the previous day's timestamp by 86400000 milliseconds, confirming the dates are consecutive.

In the header helper, Object.keys(headers) returns the header names and a for...of loop compares each name case-insensitively.

Request fewer days and remove stale output

  1. Change FORECAST_DAYS from 7 to 3.

  2. Predict the sheet layout before running: row 1 holds the header, A2:C4 holds three forecast days, A5:C8 is padded with blank cells, and A9:C12 keeps the attribution and marker rows.

  3. Save and run importForecastToSheet. Confirm the log shows Wrote 3 days of forecast. and A5:C8 is blank.

  4. Restore FORECAST_DAYS = 7, save, and rerun to restore the seven-day table.

To test the input guard, set FORECAST_DAYS to 8 and run importForecastToSheet. The function throws Set FORECAST_DAYS to a whole number from 1 to 7. before making an HTTP request or modifying the sheet. Restore 7 afterward.

Recover without erasing the previous forecast

FailureWhat to inspect and what remains
Missing active spreadsheet, missing Forecast, or an unowned/merged destinationOpen the bound project from the Forecast practice sheet. These checks run before the HTTP request.
Fetch throws without a responseCheck the error message for missing OAuth consent, quota exhaustion, or network failure. No cells were written.
HTTP 400Open-Meteo returns 400 for invalid query parameters. Fix the parameters before rerunning; the previous sheet values remain untouched.
HTTP 429 or 5xxStop and wait before retrying; the forecast script does not auto-retry failed requests.
Unexpected content type, invalid JSON, or invalid data fieldsThe validation guard rejects the response before setValues(), leaving the previous forecast in place.
Uncertain completion during the final writeInspect Forecast!A1:C12 before running again.

Read a small sample from a paginated API

A page is one slice of a larger collection. GitHub's list-repository-tags endpoint returns tag records in pages controlled by per_page and page query parameters. The second project fetches up to two pages (per_page=5) of public tags from microsoft/TypeScript, sending Accept: application/vnd.github+json, a User-Agent header, and X-GitHub-Api-Version: 2026-03-10 (GitHub API versions).

GitHub allows unauthenticated reads of public repositories at up to 60 requests per hour per source IP, shared across callers on Google's outbound IP pool.

Code.gs
const TAG_MAX_PAGES = 2;
const TAG_MAX_ITEMS = 10;
const TAG_PAGE_SIZE = 5;
const TAG_ENDPOINT = 'https://api.github.com/repos/microsoft/TypeScript/tags';

/** @typedef {{name: string, commit: {sha: string}}} RepositoryTag */
/**
 * A property-access view only; name and SHA are unknown until readTagPage validates them.
 * @typedef {{name?: unknown, commit?: {sha?: unknown}|null}|null|undefined} TagCandidate
 */

function listPublicRepositoryTags() {
  if (!Number.isInteger(TAG_MAX_PAGES) || TAG_MAX_PAGES < 1 || TAG_MAX_PAGES > 3 ||
      !Number.isInteger(TAG_MAX_ITEMS) || TAG_MAX_ITEMS < 1 || TAG_MAX_ITEMS > 15 ||
      TAG_PAGE_SIZE !== 5) {
    throw new Error('Use 1-3 pages, 1-15 items, and a page size of 5.');
  }

  const progress = { httpAttempts: 0 };
  const tags = [];
  let pagesRead = 0;
  /** @type {string|null} */
  let url = TAG_ENDPOINT + '?per_page=' + TAG_PAGE_SIZE + '&page=1';
  let complete = false;
  let stopReason = '';
  /** @type {unknown} */
  let failure = null;

  try {
    while (url !== null) {
      if (pagesRead >= TAG_MAX_PAGES) {
        stopReason = 'page limit reached; more pages available';
        break;
      }
      if (tags.length >= TAG_MAX_ITEMS) {
        stopReason = 'item limit reached; more data available';
        break;
      }
      checkTagPageUrl(url, pagesRead + 1);
      const response = fetchTagPage(url, progress);
      const headers = response.getAllHeaders();
      const page = readTagPage(response, headers);
      const nextUrl = nextTagPageUrl(headers);
      if (nextUrl !== null) checkTagPageUrl(nextUrl, pagesRead + 2);

      const room = TAG_MAX_ITEMS - tags.length;
      for (const tag of page) {
        if (tags.length >= TAG_MAX_ITEMS) break;
        tags.push({ name: tag.name, sha: tag.commit.sha });
      }
      pagesRead = pagesRead + 1;
      if (page.length > room) {
        stopReason = 'item limit reached within a validated page';
        break;
      }
      url = nextUrl;
      if (url === null) {
        complete = true;
        stopReason = 'no next link reported';
      } else if (pagesRead < TAG_MAX_PAGES && tags.length < TAG_MAX_ITEMS &&
                 tagHeader(headers, 'x-ratelimit-remaining') === '0') {
        throw new Error('GitHub reports no requests remaining. ' + tagWaitDetails(headers));
      }
    }
  } catch (error) {
    failure = error;
    stopReason = String(error);
  }

  let resultLabel = 'partial';
  if (complete) resultLabel = 'complete';
  Logger.log('Repository: microsoft/TypeScript');
  Logger.log('Pages accepted: ' + pagesRead + '; HTTP attempts: ' + progress.httpAttempts);
  Logger.log('Tags collected: ' + tags.length);
  Logger.log('Result: ' + resultLabel + '; ' + stopReason);
  Logger.log('Tag names: ' + JSON.stringify(tags.map((tag) => tag.name)));
  if (failure !== null) throw failure;
}

/**
 * @param {string} url
 * @param {{httpAttempts: number}} progress
 * @returns {UrlFetchApp.HTTPResponse}
 */
function fetchTagPage(url, progress) {
  let attempt = 0;
  while (attempt < 3) {
    attempt = attempt + 1;
    progress.httpAttempts = progress.httpAttempts + 1;
    // Exceptions without an HTTP response are not automatically retried.
    const response = UrlFetchApp.fetch(url, {
      method: 'get',
      headers: {
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2026-03-10',
        'User-Agent': 'apps-script-book-http-pagination'
      },
      muteHttpExceptions: true,
      followRedirects: false
    });
    const status = response.getResponseCode();
    const headers = response.getAllHeaders();
    if (status === 200) return response;

    if (status === 403 || status === 429 ||
        tagHeader(headers, 'retry-after') !== '' ||
        tagHeader(headers, 'x-ratelimit-remaining') === '0') {
      throw new Error('GitHub HTTP ' + status + '; no automatic retry. ' + tagWaitDetails(headers));
    }
    const transient = status === 500 || status === 502 || status === 503 || status === 504;
    if (!transient || attempt === 3) {
      throw new Error('GitHub HTTP ' + status + ' after ' + attempt + ' attempt(s) for this page. Requests stopped.');
    }
    // Only these read-only GET failures are retried: wait 1 second, then 2 seconds.
    Utilities.sleep(attempt * 1000);
  }
  throw new Error('The page attempt limit was reached.');
}

/**
 * @param {UrlFetchApp.HTTPResponse} response
 * @param {Record<string, string|string[]>} headers
 * @returns {RepositoryTag[]}
 */
function readTagPage(response, headers) {
  const contentType = tagHeader(headers, 'content-type').split(';')[0].trim().toLowerCase();
  if (contentType !== 'application/json' && contentType !== 'application/vnd.github+json') {
    throw new Error('GitHub returned an unexpected content type.');
  }
  const text = response.getContentText('UTF-8');
  if (text.length > 100000) {
    throw new Error('The GitHub response exceeded the 100000-character parsing limit.');
  }
  /** @type {unknown} */
  let page;
  try {
    page = JSON.parse(text);
  } catch (error) {
    throw new Error('GitHub returned invalid JSON.');
  }
  if (!Array.isArray(page) || page.length > TAG_PAGE_SIZE) {
    throw new Error('Expected an array containing at most five GitHub tags.');
  }
  for (const tag of /** @type {TagCandidate[]} */ (page)) {
    if (!tag || typeof tag !== 'object' || Array.isArray(tag) ||
        typeof tag.name !== 'string' || tag.name.length === 0 || tag.name.length > 256 ||
        !tag.commit || typeof tag.commit.sha !== 'string' ||
        !/^[0-9a-f]{40}$/.test(tag.commit.sha)) {
      throw new Error('A GitHub tag is missing its expected name or commit SHA.');
    }
  }
  return /** @type {RepositoryTag[]} */ (page);
}

/**
 * @param {Record<string, string|string[]>} headers
 * @returns {string|null}
 */
function nextTagPageUrl(headers) {
  const link = tagHeader(headers, 'link');
  if (link === '') return null;
  if (link.length > 8000) throw new Error('GitHub pagination headers exceeded this example\'s limit.');
  /** @type {string|null} */
  let next = null;
  for (const entry of link.split(',')) {
    const match = entry.trim().match(/^<([^<>]+)>;\s*rel="(next|prev|first|last)"$/);
    if (!match) throw new Error('GitHub returned an unsupported pagination link format.');
    if (match[2] === 'next') {
      if (next !== null) throw new Error('GitHub returned more than one next-page link.');
      next = match[1];
    }
  }
  return next;
}

/**
 * @param {string} url
 * @param {number} expectedPage
 * @returns {void}
 */
function checkTagPageUrl(url, expectedPage) {
  // GitHub may use this repository's numeric API path in pagination links.
  const namedPrefix = TAG_ENDPOINT + '?';
  const numericPrefix = 'https://api.github.com/repositories/20929025/tags?';
  let query;
  if (url.startsWith(namedPrefix)) {
    query = url.slice(namedPrefix.length);
  } else if (url.startsWith(numericPrefix)) {
    query = url.slice(numericPrefix.length);
  } else {
    throw new Error('Refusing a page URL outside the expected GitHub repository tags endpoint.');
  }
  const parameters = query.split('&');
  if (parameters.length !== 2) throw new Error('Refusing unexpected pagination query parameters.');
  /** @type {string|null} */
  let page = null;
  /** @type {string|null} */
  let pageSize = null;
  for (const parameter of parameters) {
    const pair = parameter.split('=');
    if (pair.length !== 2) throw new Error('Refusing a malformed pagination query.');
    if (pair[0] === 'page' && page === null) {
      page = pair[1];
    } else if (pair[0] === 'per_page' && pageSize === null) {
      pageSize = pair[1];
    } else {
      throw new Error('Refusing unknown or repeated pagination parameters.');
    }
  }
  if (page !== String(expectedPage) || pageSize !== String(TAG_PAGE_SIZE)) {
    throw new Error('Refusing a repeated, skipped or resized next page.');
  }
}

/**
 * @param {Record<string, string|string[]>} headers
 * @param {string} wantedName
 * @returns {string}
 */
function tagHeader(headers, wantedName) {
  for (const name of Object.keys(headers)) {
    if (name.toLowerCase() === wantedName) {
      const value = headers[name];
      if (Array.isArray(value)) return value.join(', ');
      return String(value);
    }
  }
  return '';
}

/**
 * @param {Record<string, string|string[]>} headers
 * @returns {string}
 */
function tagWaitDetails(headers) {
  const details = {
    retryAfterSeconds: tagHeader(headers, 'retry-after').slice(0, 80),
    remaining: tagHeader(headers, 'x-ratelimit-remaining').slice(0, 80),
    resetEpochSeconds: tagHeader(headers, 'x-ratelimit-reset').slice(0, 80)
  };
  return 'Rate headers: ' + JSON.stringify(details) +
    '. Honor Retry-After and any exhausted-budget reset before another run. ' +
    'For a secondary limit without those headers, wait at least one minute; do not repeatedly rerun. ' +
    'A 403 can also require a permission or policy fix.';
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request"
  ]
}

Download appsscript.json
  1. Create a new standalone project named Public tag pages.

  2. Replace Code.gs with the pagination example file, replace appsscript.json with its included manifest, and save both files.

  3. Keep TAG_MAX_PAGES = 2, TAG_MAX_ITEMS = 10, and TAG_PAGE_SIZE = 5.

  4. Select listPublicRepositoryTags and click Run, approving the script.external_request scope if prompted.

  5. Inspect Execution log for the page count, HTTP attempt count, collected tag count, result status, and tag names.

When two full pages succeed on the first attempt and more pages remain on GitHub, the log includes:

Source
Repository: microsoft/TypeScript
Pages accepted: 2; HTTP attempts: 2
Tags collected: 10
Result: partial; page limit reached; more pages available

Followed by Tag names: with a JSON array of the 10 tag names. Because the run stopped at TAG_MAX_PAGES = 2 while a next link was still present, the summary reports partial rather than presenting the 10 tags as the complete repository list.

GitHub's response includes a Link header with pagination URLs such as rel="next" (GitHub pagination guide). Rather than constructing page URLs manually, the script parses the rel="next" URL from the Link header and validates it before fetching the next page:

  • The URL must begin with https://api.github.com/repos/microsoft/TypeScript/tags? or the repository's canonical ID path https://api.github.com/repositories/20929025/tags?.

  • Its query string may contain only page and per_page, with per_page=5 and page equal to the next sequential page number.

Although GitHub recommends following redirects in general clients, this script disables redirects so any moved or renamed repository URL stops for manual review.

Bound pages, records and retries separately

For each page, the script requires HTTP 200, a JSON content type, a body of at most 100,000 characters, a JSON array of at most five objects, and a non-empty tag name plus a 40-character hex commit SHA on every item.

TAG_MAX_PAGES bounds validated pages, while TAG_MAX_ITEMS bounds retained items. When TAG_MAX_ITEMS is reached mid-page, the inner for...of loop exits with break, and the outer loop exits with break so the entrypoint can log its summary.

The progress object tracks httpAttempts (incremented before every UrlFetchApp.fetch call, including retries) separately from pagesRead (incremented only after a page passes validation). With at most three attempts per page and TAG_MAX_PAGES = 2, a baseline run makes at most six HTTP requests.

These counters bound request volume per run, while Apps Script enforces overall execution time, URL Fetch call, and response size quotas.

Retry only the failure you understand

Unlike the single-attempt forecast script, the GitHub pagination helper retries transient server errors (500, 502, 503, 504) up to three total attempts per page when no rate-limit signal is present, waiting 1 second before the second attempt and 2 seconds before the third:

ResultGitHub client's decision
Fetch throws without an HTTP responseStop and rethrow; the failure may be missing consent, DNS/TLS failure, or an Apps Script quota.
400, 401, 404, 410 or another client errorStop immediately; retrying an unchanged request will not fix a client error.
403 or 429Stop without retrying (403 can indicate either a rate limit or an access refusal).
Retry-After header or x-ratelimit-remaining: 0Stop and log the rate-limit headers instead of sleeping through a long reset window.
500, 502, 503, 504 with remaining rate budgetRetry the GET request up to three total attempts with 1 s and 2 s delays.
200 with invalid JSON, item schema, or Link headerStop and reject the page.

GitHub's rate-limit documentation specifies waiting for Retry-After seconds when present, or until x-ratelimit-reset (UTC epoch seconds) when x-ratelimit-remaining reaches 0. If an accepted page reports x-ratelimit-remaining: 0, the loop stops before requesting the next page.

Only idempotent GET reads belong in this retry loop. Do not wrap POST requests, email sends, or file creation in a blind retry loop: if a network drop occurs after the remote server processes a write, retrying the request can duplicate the action.

Change the page limit and interpret a partial result

  1. Change TAG_MAX_PAGES from 2 to 1, keeping TAG_MAX_ITEMS = 10 and TAG_PAGE_SIZE = 5.

  2. Save and run listPublicRepositoryTags. With a full first page and no retry, expect Pages accepted: 1; HTTP attempts: 1, Tags collected: 5, and Result: partial; page limit reached; more pages available.

  3. Restore TAG_MAX_PAGES = 2 and save.

To test the item limit within a page, keep TAG_MAX_PAGES = 2, set TAG_MAX_ITEMS = 3, and run once. The first five-item page is validated, three items are retained, and the log reports Result: partial; item limit reached within a validated page. Restore TAG_MAX_ITEMS = 10 afterward.

Even when a paginated traversal reaches the final page (complete), remote callers can create or delete tags between page requests, shifting page boundaries (GitHub pagination guide).

Finish the manual workflow before scheduling it

Restore FORECAST_DAYS = 7 in Forecast practice and TAG_MAX_PAGES = 2, TAG_MAX_ITEMS = 10 in Public tag pages. Retain the Open-Meteo attribution rows (A9:B11) with any forecast data you keep.

Next, store state and cache data across runs so your script can remember configuration and avoid repeating network calls.

Project files

Complete source files for this chapter’s examples.

Preview an HTTP request without sending it
Code.gs
function previewRequest() {
  const baseUrl = 'https://example.com/api/requests';
  const query = 'status=open&limit=10';
  const request = UrlFetchApp.getRequest(baseUrl + '?' + query, {
    headers: { Accept: 'application/json' },
    method: 'get',
  });

  Logger.log(JSON.stringify(request, null, 2));
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request"
  ]
}

sample-request.json

Download file
sample-request.json
{
  "baseUrl": "https://example.com/api/requests",
  "query": {
    "status": "open",
    "limit": 10
  },
  "headers": {
    "Accept": "application/json"
  }
}

Import a bounded public forecast into Sheets
Code.gs
const FORECAST_DAYS = 7;
const FORECAST_LATITUDE = 52.52;
const FORECAST_LONGITUDE = 13.41;
const FORECAST_OWNER_MARKER = 'Owned by weather-import';

/**
 * A property-access view for validation, not a validated provider response.
 * Every data field remains unknown until the checks in forecastRows succeed.
 * @typedef {{daily?: {time?: unknown, temperature_2m_max?: unknown, temperature_2m_min?: unknown}|null, daily_units?: {temperature_2m_max?: unknown, temperature_2m_min?: unknown}|null}} ForecastCandidate
 */

function importForecastToSheet() {
  if (!Number.isInteger(FORECAST_DAYS) || FORECAST_DAYS < 1 || FORECAST_DAYS > 7) {
    throw new Error('Set FORECAST_DAYS to a whole number from 1 to 7.');
  }
  if (!Number.isFinite(FORECAST_LATITUDE) || Math.abs(FORECAST_LATITUDE) > 90 ||
      !Number.isFinite(FORECAST_LONGITUDE) || Math.abs(FORECAST_LONGITUDE) > 180) {
    throw new Error('Use numeric latitude from -90 to 90 and longitude from -180 to 180.');
  }

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) {
    throw new Error('Open this project through Extensions > Apps Script in the practice spreadsheet.');
  }
  const sheet = spreadsheet.getSheetByName('Forecast');
  if (!sheet) {
    throw new Error('Create a blank tab named Forecast before running this function.');
  }
  const target = sheet.getRange('A1:C12');
  checkForecastDestination(target, sheet);

  const query = [
    'latitude=' + encodeURIComponent(FORECAST_LATITUDE),
    'longitude=' + encodeURIComponent(FORECAST_LONGITUDE),
    'daily=temperature_2m_max,temperature_2m_min',
    'temperature_unit=celsius',
    'timezone=auto',
    'forecast_days=' + FORECAST_DAYS
  ].join('&');
  const response = UrlFetchApp.fetch('https://api.open-meteo.com/v1/forecast?' + query, {
    method: 'get',
    headers: { Accept: 'application/json' },
    muteHttpExceptions: true,
    followRedirects: false
  });
  const statusCode = response.getResponseCode();
  if (statusCode !== 200) {
    throw new Error('The forecast service answered with status code ' + statusCode + '. No cells were written.');
  }
  const contentType = forecastHeader(response.getAllHeaders(), 'content-type');
  if (contentType.split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new Error('The forecast response was not application/json. No cells were written.');
  }
  const text = response.getContentText('UTF-8');
  if (text.length > 50000) {
    throw new Error('The forecast response exceeded the 50000-character parsing limit.');
  }
  /** @type {unknown} */
  let forecast;
  try {
    forecast = JSON.parse(text);
  } catch (error) {
    throw new Error('The forecast response was not valid JSON. No cells were written.');
  }

  const rows = forecastRows(forecast, FORECAST_DAYS);
  // Check again after the request in case the destination changed while it was pending.
  checkForecastDestination(target, sheet);
  target.setValues(rows);
  Logger.log('Wrote ' + FORECAST_DAYS + ' days of forecast.');
}

/**
 * @param {SpreadsheetApp.Range} target
 * @param {SpreadsheetApp.Sheet} sheet
 * @returns {void}
 */
function checkForecastDestination(target, sheet) {
  if (target.isPartOfMerge()) {
    throw new Error('Forecast!A1:C12 must not overlap merged cells.');
  }
  if (target.isBlank()) return;
  const markerCell = sheet.getRange('A12');
  if (markerCell.getValue() !== FORECAST_OWNER_MARKER || markerCell.getFormula() !== '') {
    throw new Error('Forecast!A1:C12 is not blank and has no valid ownership marker. Use a new practice sheet; do not clear existing data to bypass this check.');
  }
}

/**
 * @param {unknown} rawForecast
 * @param {number} expectedDays
 * @returns {Array<Array<string|number>>}
 */
function forecastRows(rawForecast, expectedDays) {
  // This view permits property probes; the original runtime checks still validate the value.
  const forecast = /** @type {ForecastCandidate|null|undefined} */ (rawForecast);
  if (!forecast || typeof forecast !== 'object' || Array.isArray(forecast) ||
      !forecast.daily || !forecast.daily_units) {
    throw new Error('The forecast response is missing daily data or units.');
  }
  const days = forecast.daily.time;
  const highs = forecast.daily.temperature_2m_max;
  const lows = forecast.daily.temperature_2m_min;
  if (!Array.isArray(days) || !Array.isArray(highs) || !Array.isArray(lows) ||
      days.length !== expectedDays || highs.length !== expectedDays || lows.length !== expectedDays) {
    throw new Error('The forecast arrays must each contain exactly ' + expectedDays + ' days.');
  }
  if (forecast.daily_units.temperature_2m_max !== '°C' ||
      forecast.daily_units.temperature_2m_min !== '°C') {
    throw new Error('The forecast temperature units must be Celsius.');
  }

  /** @type {Array<Array<string|number>>} */
  const rows = [['Date', 'High', 'Low']];
  let index = 0;
  /** @type {number|null} */
  let previousDay = null;
  while (index < expectedDays) {
    /** @type {unknown} */
    const day = days[index];
    /** @type {unknown} */
    const high = highs[index];
    /** @type {unknown} */
    const low = lows[index];
    if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) {
      throw new Error('Forecast day ' + (index + 1) + ' is not a YYYY-MM-DD date.');
    }
    const timestamp = Date.parse(day + 'T00:00:00Z');
    if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== day ||
        (previousDay !== null && timestamp - previousDay !== 86400000)) {
      throw new Error('Forecast dates must be valid consecutive calendar days.');
    }
    if (!Number.isFinite(high) || !Number.isFinite(low) ||
        /** @type {number} */ (high) < /** @type {number} */ (low)) {
      throw new Error('Forecast day ' + (index + 1) + ' needs numeric high and low temperatures with high >= low.');
    }
    rows.push([day, /** @type {number} */ (high), /** @type {number} */ (low)]);
    previousDay = timestamp;
    index = index + 1;
  }
  // The fixed output area has room for seven days, even when fewer are requested.
  while (rows.length < 8) {
    rows.push(['', '', '']);
  }
  rows.push(['Weather data by Open-Meteo', 'https://open-meteo.com/', '']);
  rows.push(['Licence: CC BY 4.0', 'https://creativecommons.org/licenses/by/4.0/', '']);
  rows.push(['Selected daily highs/lows; Celsius; local dates', '', '']);
  rows.push([FORECAST_OWNER_MARKER, '', '']);
  return rows;
}

/**
 * @param {Record<string, string|string[]>} headers
 * @param {string} wantedName
 * @returns {string}
 */
function forecastHeader(headers, wantedName) {
  for (const name of Object.keys(headers)) {
    if (name.toLowerCase() === wantedName) {
      const value = headers[name];
      if (Array.isArray(value)) return value.join(', ');
      return String(value);
    }
  }
  return '';
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/spreadsheets.currentonly"
  ]
}

Read a bounded sample of public GitHub tags
Code.gs
const TAG_MAX_PAGES = 2;
const TAG_MAX_ITEMS = 10;
const TAG_PAGE_SIZE = 5;
const TAG_ENDPOINT = 'https://api.github.com/repos/microsoft/TypeScript/tags';

/** @typedef {{name: string, commit: {sha: string}}} RepositoryTag */
/**
 * A property-access view only; name and SHA are unknown until readTagPage validates them.
 * @typedef {{name?: unknown, commit?: {sha?: unknown}|null}|null|undefined} TagCandidate
 */

function listPublicRepositoryTags() {
  if (!Number.isInteger(TAG_MAX_PAGES) || TAG_MAX_PAGES < 1 || TAG_MAX_PAGES > 3 ||
      !Number.isInteger(TAG_MAX_ITEMS) || TAG_MAX_ITEMS < 1 || TAG_MAX_ITEMS > 15 ||
      TAG_PAGE_SIZE !== 5) {
    throw new Error('Use 1-3 pages, 1-15 items, and a page size of 5.');
  }

  const progress = { httpAttempts: 0 };
  const tags = [];
  let pagesRead = 0;
  /** @type {string|null} */
  let url = TAG_ENDPOINT + '?per_page=' + TAG_PAGE_SIZE + '&page=1';
  let complete = false;
  let stopReason = '';
  /** @type {unknown} */
  let failure = null;

  try {
    while (url !== null) {
      if (pagesRead >= TAG_MAX_PAGES) {
        stopReason = 'page limit reached; more pages available';
        break;
      }
      if (tags.length >= TAG_MAX_ITEMS) {
        stopReason = 'item limit reached; more data available';
        break;
      }
      checkTagPageUrl(url, pagesRead + 1);
      const response = fetchTagPage(url, progress);
      const headers = response.getAllHeaders();
      const page = readTagPage(response, headers);
      const nextUrl = nextTagPageUrl(headers);
      if (nextUrl !== null) checkTagPageUrl(nextUrl, pagesRead + 2);

      const room = TAG_MAX_ITEMS - tags.length;
      for (const tag of page) {
        if (tags.length >= TAG_MAX_ITEMS) break;
        tags.push({ name: tag.name, sha: tag.commit.sha });
      }
      pagesRead = pagesRead + 1;
      if (page.length > room) {
        stopReason = 'item limit reached within a validated page';
        break;
      }
      url = nextUrl;
      if (url === null) {
        complete = true;
        stopReason = 'no next link reported';
      } else if (pagesRead < TAG_MAX_PAGES && tags.length < TAG_MAX_ITEMS &&
                 tagHeader(headers, 'x-ratelimit-remaining') === '0') {
        throw new Error('GitHub reports no requests remaining. ' + tagWaitDetails(headers));
      }
    }
  } catch (error) {
    failure = error;
    stopReason = String(error);
  }

  let resultLabel = 'partial';
  if (complete) resultLabel = 'complete';
  Logger.log('Repository: microsoft/TypeScript');
  Logger.log('Pages accepted: ' + pagesRead + '; HTTP attempts: ' + progress.httpAttempts);
  Logger.log('Tags collected: ' + tags.length);
  Logger.log('Result: ' + resultLabel + '; ' + stopReason);
  Logger.log('Tag names: ' + JSON.stringify(tags.map((tag) => tag.name)));
  if (failure !== null) throw failure;
}

/**
 * @param {string} url
 * @param {{httpAttempts: number}} progress
 * @returns {UrlFetchApp.HTTPResponse}
 */
function fetchTagPage(url, progress) {
  let attempt = 0;
  while (attempt < 3) {
    attempt = attempt + 1;
    progress.httpAttempts = progress.httpAttempts + 1;
    // Exceptions without an HTTP response are not automatically retried.
    const response = UrlFetchApp.fetch(url, {
      method: 'get',
      headers: {
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2026-03-10',
        'User-Agent': 'apps-script-book-http-pagination'
      },
      muteHttpExceptions: true,
      followRedirects: false
    });
    const status = response.getResponseCode();
    const headers = response.getAllHeaders();
    if (status === 200) return response;

    if (status === 403 || status === 429 ||
        tagHeader(headers, 'retry-after') !== '' ||
        tagHeader(headers, 'x-ratelimit-remaining') === '0') {
      throw new Error('GitHub HTTP ' + status + '; no automatic retry. ' + tagWaitDetails(headers));
    }
    const transient = status === 500 || status === 502 || status === 503 || status === 504;
    if (!transient || attempt === 3) {
      throw new Error('GitHub HTTP ' + status + ' after ' + attempt + ' attempt(s) for this page. Requests stopped.');
    }
    // Only these read-only GET failures are retried: wait 1 second, then 2 seconds.
    Utilities.sleep(attempt * 1000);
  }
  throw new Error('The page attempt limit was reached.');
}

/**
 * @param {UrlFetchApp.HTTPResponse} response
 * @param {Record<string, string|string[]>} headers
 * @returns {RepositoryTag[]}
 */
function readTagPage(response, headers) {
  const contentType = tagHeader(headers, 'content-type').split(';')[0].trim().toLowerCase();
  if (contentType !== 'application/json' && contentType !== 'application/vnd.github+json') {
    throw new Error('GitHub returned an unexpected content type.');
  }
  const text = response.getContentText('UTF-8');
  if (text.length > 100000) {
    throw new Error('The GitHub response exceeded the 100000-character parsing limit.');
  }
  /** @type {unknown} */
  let page;
  try {
    page = JSON.parse(text);
  } catch (error) {
    throw new Error('GitHub returned invalid JSON.');
  }
  if (!Array.isArray(page) || page.length > TAG_PAGE_SIZE) {
    throw new Error('Expected an array containing at most five GitHub tags.');
  }
  for (const tag of /** @type {TagCandidate[]} */ (page)) {
    if (!tag || typeof tag !== 'object' || Array.isArray(tag) ||
        typeof tag.name !== 'string' || tag.name.length === 0 || tag.name.length > 256 ||
        !tag.commit || typeof tag.commit.sha !== 'string' ||
        !/^[0-9a-f]{40}$/.test(tag.commit.sha)) {
      throw new Error('A GitHub tag is missing its expected name or commit SHA.');
    }
  }
  return /** @type {RepositoryTag[]} */ (page);
}

/**
 * @param {Record<string, string|string[]>} headers
 * @returns {string|null}
 */
function nextTagPageUrl(headers) {
  const link = tagHeader(headers, 'link');
  if (link === '') return null;
  if (link.length > 8000) throw new Error('GitHub pagination headers exceeded this example\'s limit.');
  /** @type {string|null} */
  let next = null;
  for (const entry of link.split(',')) {
    const match = entry.trim().match(/^<([^<>]+)>;\s*rel="(next|prev|first|last)"$/);
    if (!match) throw new Error('GitHub returned an unsupported pagination link format.');
    if (match[2] === 'next') {
      if (next !== null) throw new Error('GitHub returned more than one next-page link.');
      next = match[1];
    }
  }
  return next;
}

/**
 * @param {string} url
 * @param {number} expectedPage
 * @returns {void}
 */
function checkTagPageUrl(url, expectedPage) {
  // GitHub may use this repository's numeric API path in pagination links.
  const namedPrefix = TAG_ENDPOINT + '?';
  const numericPrefix = 'https://api.github.com/repositories/20929025/tags?';
  let query;
  if (url.startsWith(namedPrefix)) {
    query = url.slice(namedPrefix.length);
  } else if (url.startsWith(numericPrefix)) {
    query = url.slice(numericPrefix.length);
  } else {
    throw new Error('Refusing a page URL outside the expected GitHub repository tags endpoint.');
  }
  const parameters = query.split('&');
  if (parameters.length !== 2) throw new Error('Refusing unexpected pagination query parameters.');
  /** @type {string|null} */
  let page = null;
  /** @type {string|null} */
  let pageSize = null;
  for (const parameter of parameters) {
    const pair = parameter.split('=');
    if (pair.length !== 2) throw new Error('Refusing a malformed pagination query.');
    if (pair[0] === 'page' && page === null) {
      page = pair[1];
    } else if (pair[0] === 'per_page' && pageSize === null) {
      pageSize = pair[1];
    } else {
      throw new Error('Refusing unknown or repeated pagination parameters.');
    }
  }
  if (page !== String(expectedPage) || pageSize !== String(TAG_PAGE_SIZE)) {
    throw new Error('Refusing a repeated, skipped or resized next page.');
  }
}

/**
 * @param {Record<string, string|string[]>} headers
 * @param {string} wantedName
 * @returns {string}
 */
function tagHeader(headers, wantedName) {
  for (const name of Object.keys(headers)) {
    if (name.toLowerCase() === wantedName) {
      const value = headers[name];
      if (Array.isArray(value)) return value.join(', ');
      return String(value);
    }
  }
  return '';
}

/**
 * @param {Record<string, string|string[]>} headers
 * @returns {string}
 */
function tagWaitDetails(headers) {
  const details = {
    retryAfterSeconds: tagHeader(headers, 'retry-after').slice(0, 80),
    remaining: tagHeader(headers, 'x-ratelimit-remaining').slice(0, 80),
    resetEpochSeconds: tagHeader(headers, 'x-ratelimit-reset').slice(0, 80)
  };
  return 'Rate headers: ' + JSON.stringify(details) +
    '. Honor Retry-After and any exhausted-budget reset before another run. ' +
    'For a secondary limit without those headers, wait at least one minute; do not repeatedly rerun. ' +
    'A 403 can also require a permission or policy fix.';
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request"
  ]
}