Skip to content
Skip to chapter

Chapter 27

Development and operations

Debug executions and recover from limits

Debug Google Apps Script with Logger.log, console.log, and execution history, stay inside the 6-minute run limit, and recover failed runs cleanly.

By the end

Find a failing input with the debugger, interpret correlated logs, and decide whether to repair, resume, or reconcile an audit.

Chapter navigation

Find the input that caused a failure before changing the script or running it again. You will pause a status counter, trace a failed batch, and resume a read-only audit from its checkpoint. Use the arrays, validation, and shared-state ideas from the request tracker, but install this diagnostic project separately. It does not change spreadsheet cells, send email, or call an external API.

Set up an independent diagnostic table

Use a new test spreadsheet and the sample data. Do not replace the capstone's code or import this fixture into its working table.

  1. Create a spreadsheet and name a tab Requests.

  2. Copy the sample.csv text shown below into A1. If the comma-separated text remains in column A, select that column, choose Data > Split text to columns, and choose Comma. If it already occupies separate columns, do not split again. Verify the header and four request rows occupy A1:E5, with nothing outside those five columns.

  3. Open Extensions > Apps Script from this spreadsheet. In Project Settings, enable Show "appsscript.json" manifest file in editor, then return to Editor.

  4. Replace the new project's default Code.gs with the complete file below and replace appsscript.json with the included manifest. Save both files. Keep V8, Etc/UTC, and the two explicit scopes. No library, advanced service, spreadsheet ID, credential, deployment, or trigger is needed.

  5. Select inspectRequestTracker in the function selector and click Run. Review and approve the authorization prompt with the account that owns this test spreadsheet.

Manual Run and Debug execute as the person at the keyboard, with that person's authorization. The manifest's spreadsheets.currentonly scope permits access to the current spreadsheet, including edits, although this code only reads cells. script.storage permits project property storage. The inspector changes no property; auditRequestBatch stores or removes one named checkpoint. Both produce logs. The authorization guide explains identity and scope boundaries.

sample.csv
Request ID,Requester,Summary,Status,Last updated
R-400,Alex,Review the meeting notes,New,
R-401,Sam,Update the project brief,Ready,2026-09-01T09:00:00Z
R-402,Jo,Prepare a draft agenda,Hold,
R-403,Taylor,Archive approved notes,Done,2026-09-02T14:30:00Z

Download sample.csv
Code.gs
const DIAGNOSTIC_SHEET_NAME_ = 'Requests';
const DIAGNOSTIC_HEADER_ = ['Request ID', 'Requester', 'Summary', 'Status', 'Last updated'];
const DIAGNOSTIC_STATUSES_ = ['New', 'Ready', 'Hold', 'Done'];
const AUDIT_CHECKPOINT_KEY_ = 'REQUEST_TRACKER_AUDIT_NEXT_ROW';
const AUDIT_BATCH_SIZE_ = 3;
const DIAGNOSTIC_MAX_ROWS_ = 100;

/** @typedef {'New'|'Ready'|'Hold'|'Done'} DiagnosticStatus */
/** @typedef {{runId: string, handler: string, startedAt: number, stage: string, startRow: number|null, endRow: number|null, checkpointOutcome: string}} DiagnosticRun */

/**
 * Logs status counts after validating the Requests table. It does not change cells.
 *
 * @returns {void}
 */
function inspectRequestTracker() {
  const run = beginDiagnosticRun_('inspectRequestTracker');
  try {
    run.stage = 'read_and_validate';
    const rows = readDiagnosticRows_();
    run.stage = 'count_statuses';
    const counts = countStatuses_(rows);
    logDiagnostic_(run, 'inspection_complete',
      'Requests: ' + rows.length + '; New: ' + counts.New + '; Ready: ' + counts.Ready +
      '; Hold: ' + counts.Hold + '; Done: ' + counts.Done,
      { rowCount: rows.length, counts: counts });
  } catch (error) {
    reportDiagnosticFailure_(run);
    throw error;
  }
}

/**
 * Audits a bounded group of rows and stores the next row in script properties.
 * It does not alter spreadsheet cells.
 *
 * @returns {void}
 */
function auditRequestBatch() {
  const run = beginDiagnosticRun_('auditRequestBatch');
  const lock = LockService.getScriptLock();
  let locked = false;
  try {
    run.stage = 'acquire_lock';
    locked = lock.tryLock(5000);
    if (!locked) {
      logDiagnostic_(run, 'audit_skipped',
        'Skipped audit because another execution is updating the checkpoint.', {});
      return;
    }

    run.stage = 'read_header';
    const sheet = getDiagnosticSheet_();
    validateDiagnosticHeader_(sheet.getRange(1, 1, 1, DIAGNOSTIC_HEADER_.length).getValues()[0]);
    const lastRow = sheet.getLastRow();
    run.stage = 'read_checkpoint';
    const properties = PropertiesService.getScriptProperties();
    const checkpoint = properties.getProperty(AUDIT_CHECKPOINT_KEY_);
    const startRow = checkpoint === null ? 2 : Number(checkpoint);
    if (checkpoint !== null && (
      !/^[1-9]\d*$/.test(checkpoint) ||
      !Number.isSafeInteger(startRow) || startRow < 2 || startRow > lastRow
    )) {
      throw new Error(
        'Audit checkpoint is invalid or beyond the current table. Stop runs and reconcile before resetting it.'
      );
    }

    if (lastRow === 1) {
      logDiagnostic_(run, 'audit_empty', 'No request rows to audit; no checkpoint was changed.', {});
      return;
    }

    const rowCount = Math.min(AUDIT_BATCH_SIZE_, lastRow - startRow + 1);
    run.startRow = startRow;
    run.endRow = startRow + rowCount - 1;
    run.stage = 'read_rows';
    const rows = sheet.getRange(startRow, 1, rowCount, DIAGNOSTIC_HEADER_.length).getValues();
    run.stage = 'validate_rows';
    validateDiagnosticRows_(rows, startRow);
    const counts = countStatuses_(rows);
    const nextRow = startRow + rowCount;
    const complete = nextRow > lastRow;

    // A missing response from either property operation leaves its outcome uncertain.
    run.stage = complete ? 'remove_checkpoint' : 'save_checkpoint';
    run.checkpointOutcome = 'unknown';
    if (complete) {
      properties.deleteProperty(AUDIT_CHECKPOINT_KEY_);
      run.checkpointOutcome = 'remove_returned';
    } else {
      properties.setProperty(AUDIT_CHECKPOINT_KEY_, String(nextRow));
      run.checkpointOutcome = 'save_returned';
    }
    run.stage = 'report_batch';
    logDiagnostic_(run, complete ? 'audit_complete' : 'audit_progress',
      'Audited rows ' + startRow + '-' + run.endRow + '. New: ' + counts.New +
      '; Ready: ' + counts.Ready + '; Hold: ' + counts.Hold + '; Done: ' + counts.Done +
      (complete ? '. Audit is complete; checkpoint removal returned.' : '. Next row: ' + nextRow + '.'),
      { rowCount: rowCount, counts: counts, nextRow: complete ? null : nextRow });
  } catch (error) {
    reportDiagnosticFailure_(run);
    throw error;
  } finally {
    if (locked) lock.releaseLock();
  }
}

/**
 * @returns {Array<Array<*>>}
 */
function readDiagnosticRows_() {
  const sheet = getDiagnosticSheet_();
  const lastRow = sheet.getLastRow();
  if (lastRow - 1 > DIAGNOSTIC_MAX_ROWS_) {
    throw new Error('Inspection is limited to 100 request rows. Use auditRequestBatch for bounded reads.');
  }
  const values = sheet.getRange(1, 1, lastRow, DIAGNOSTIC_HEADER_.length).getValues();
  validateDiagnosticHeader_(values[0]);
  const rows = values.slice(1);
  validateDiagnosticRows_(rows, 2);
  return rows;
}

/**
 * @returns {SpreadsheetApp.Sheet}
 */
function getDiagnosticSheet_() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) {
    throw new Error('Open this project from its test spreadsheet with Extensions > Apps Script.');
  }
  const sheet = spreadsheet.getSheetByName(DIAGNOSTIC_SHEET_NAME_);
  if (!sheet) {
    throw new Error('Requests sheet is missing. Import sample.csv into a sheet named Requests.');
  }
  if (sheet.getLastColumn() !== DIAGNOSTIC_HEADER_.length) {
    throw new Error('Requests must contain exactly five used columns, A through E.');
  }
  return sheet;
}

/**
 * @param {Array<*>} header
 * @returns {void}
 */
function validateDiagnosticHeader_(header) {
  if (
    header.length !== DIAGNOSTIC_HEADER_.length ||
    header.some(function (value, index) {
      return value !== DIAGNOSTIC_HEADER_[index];
    })
  ) {
    throw new Error(
      'Requests must use this header row: Request ID, Requester, Summary, Status, Last updated.'
    );
  }
}

/**
 * @param {Array<Array<*>>} rows
 * @param {number} firstRowNumber
 * @returns {void}
 */
function validateDiagnosticRows_(rows, firstRowNumber) {
  rows.forEach(function (row, index) {
    const rowNumber = firstRowNumber + index;
    if (row.length !== DIAGNOSTIC_HEADER_.length) {
      throw new Error('Request row ' + rowNumber + ' must have five columns.');
    }
    for (let column = 0; column < 3; column += 1) {
      if (typeof row[column] !== 'string' || row[column].trim() === '') {
        throw new Error('Request row ' + rowNumber + ' needs text in columns A, B, and C.');
      }
    }
    if (DIAGNOSTIC_STATUSES_.indexOf(row[3]) === -1) {
      throw new Error(
        'Request row ' + rowNumber + ' has an unsupported status. Use New, Ready, Hold, or Done.'
      );
    }
  });
}

/**
 * @param {Array<Array<*>>} rows
 * @returns {{New: number, Ready: number, Hold: number, Done: number}}
 */
function countStatuses_(rows) {
  return rows.reduce(
    function (counts, row) {
      const status = /** @type {DiagnosticStatus} */ (row[3]);
      counts[status] += 1;
      return counts;
    },
    { New: 0, Ready: 0, Hold: 0, Done: 0 }
  );
}

/**
 * @param {string} handler
 * @returns {DiagnosticRun}
 */
function beginDiagnosticRun_(handler) {
  const run = {
    runId: Utilities.getUuid(),
    handler: handler,
    startedAt: Date.now(),
    stage: 'start',
    startRow: null,
    endRow: null,
    checkpointOutcome: 'not_attempted'
  };
  logDiagnostic_(run, 'started', 'Started ' + handler + '.', {});
  return run;
}

/**
 * Only pass bounded, non-personal diagnostic fields, never cell values or errors.
 *
 * @param {DiagnosticRun} run
 * @param {string} event
 * @param {string} message
 * @param {Object} details
 * @returns {void}
 */
function logDiagnostic_(run, event, message, details) {
  Logger.log({
    message: '[' + run.runId + '] ' + message,
    runId: run.runId,
    handler: run.handler,
    event: event,
    stage: run.stage,
    elapsedMs: Date.now() - run.startedAt,
    startRow: run.startRow,
    endRow: run.endRow,
    checkpointOutcome: run.checkpointOutcome,
    ...details
  });
}

/**
 * Add safe context, then let the caller rethrow the original exception and stack.
 *
 * @param {DiagnosticRun} run
 * @returns {void}
 */
function reportDiagnosticFailure_(run) {
  const message = 'Failed at ' + run.stage + '; checkpoint outcome: ' + run.checkpointOutcome +
    '. Inspect the exception before retrying.';
  logDiagnostic_(run, 'failed', message, {});
  console.error('[' + run.runId + '] ' + run.handler + ': ' + message);
}

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

Download appsscript.json

Open Execution log. After a start message, the inspector's expected summary is the following, prefixed with a generated run ID:

Source
Requests: 4; New: 1; Ready: 1; Hold: 1; Done: 1

inspectRequestTracker checks the sheet name, five used columns, exact header order, nonblank text in the first three columns, and the four recognized statuses. It refuses more than 100 request rows before reading the table; that is this example's bound, not a Google quota. auditRequestBatch applies those row checks only to its next three rows. It can encounter a bad later row after earlier batches have passed.

This audit checks table structure and status names rather than unique IDs, timestamps, formulas, merged cells, protected write ranges, or status transitions. The fixture includes all four states so you can count them without running the writer.

Follow a count through the debugger

A breakpoint pauses before a selected line executes. Use it to compare what the code is about to do with what you predicted, rather than adding logs of whole rows. To add one, hover over a line number and click the circle to its left. Start the debug run with Debug. The debugger instructions name the Step in, Step over, and Step out controls at the top of the Debugger panel.

Keep the original four rows for this walkthrough:

  1. In inspectRequestTracker, find const counts = countStatuses_(rows);. Click the circle to the left of its line number to add a breakpoint. There is a similar line in the audit; choose the inspector's line.

  2. Add a second breakpoint on counts[status] += 1; inside countStatuses_.

  3. Select inspectRequestTracker and click Debug. At the first pause, expand rows in the Debugger panel. Its length should be 4 and rows[0][3] should be 'New'. run.stage is 'count_statuses'. The local counts has not been assigned yet because its line has not run.

  4. Click Step in. The debugger enters countStatuses_ and pauses at return rows.reduce(. Click Step over once. It invokes reduce and stops at the second breakpoint before the first increment, without requiring you to step into the array method's internal implementation.

  5. At this pause, status should be 'New', and the accumulator counts should contain four zeroes. Click Step over once. counts.New becomes 1 while the other counts stay 0. You have run the increment without entering another helper.

  6. Remove the second breakpoint. Click Step out to leave the callback, then use the debugger's resume control to finish the remaining rows. The final log should show one of each status. Remove the first breakpoint before later normal runs.

Array index 0 refers to spreadsheet row 2 because the helper has already removed the header. Inspecting that relationship explains many off-by-one errors.

Pausing changes the timing of the execution. Other users and scheduled work continue while your code is stopped, and a lock acquired before a breakpoint remains held while you inspect it. Debug the inspector first because it holds no lock and changes no checkpoint.

Make diagnostic records useful

Each entrypoint creates a runId, a correlation ID used to group messages from one invocation. It appears in square brackets in every application message and as a separate structured field. A second invocation gets a new ID, even when it repeats the same failed batch. This ID is neither Apps Script's execution ID nor an idempotency key that prevents duplicate work.

logDiagnostic_ sends a small object to Logger.log. Its message is readable in Execution log; JSON-serializable fields are available as jsonPayload in supported Cloud Logging views. The Logger reference documents that distinction. Do not assume that the editor's stream displays every field.

FieldQuestion it answers
runId, handlerWhich invocation and entrypoint produced this message?
eventDid it start, finish inspection, advance an audit, finish an audit, skip a lock, or fail?
stageWas it reading rows, validating them, or attempting a checkpoint operation?
startRow, endRowWhich spreadsheet rows were involved in this batch? null means no batch was selected.
counts, rowCountWhat did this inspection or batch contain? These are not cumulative audit totals.
nextRowWhere should the next batch start? It is null on the last batch.
checkpointOutcomeWas a property change not attempted, uncertain, or observed to return?
elapsedMsHow much wall-clock time passed since this invocation created its diagnostic context?

On a caught failure, the code adds safe context through Logger and a text console.error message, then rethrows the original exception. The failed execution and its stack trace remain visible. Logging an error and returning normally would hide the failure from execution status. console.error adds an error-level log entry but does not mark the execution as failed.

Use Logger for structured objects. The current console reference says console serializes objects to strings and does not support jsonPayload, despite an object example in the general logging guide. console.time(label) and console.timeEnd(label) are useful for timing a selected operation; this example instead records elapsed time in each structured record. Neither method is a quota meter.

The unsupported-status error names the row and allowed statuses without echoing the bad cell. An invalid status cell could contain an email address or pasted token. Do not log entire event objects, request or response bodies, authorization headers, property stores, or exception objects from external APIs indiscriminately. Built-in service exceptions can still contain sensitive context: inspect and redact them before sharing. A timeout can stop execution before the catch block or final log, so an absent completion message is evidence to investigate, not proof that nothing happened.

Find the execution, not only the latest message

The logging guide calls Execution log a short-lived development stream. It follows the current editor run or debug session. For a past failure:

  1. Open the project's Executions page.

  2. Choose Add a filter > Status and select Failed and Timed out.

  3. Open the relevant row. Record its start time, duration, initial function, execution type, error, and stack location. Compare its run ID and last reported stage with any available logs.

  4. Clear the status filter to compare the failure with the earlier successful or skipped run. Completed means the invocation returned without an uncaught failure; it does not mean every batch in an audit is finished.

The separate Apps Script dashboard at script.google.com has My Executions across projects visible to your account and executions that ran on your behalf. The project's Executions page narrows the investigation to this project. The dashboard guide defines the row fields: Function names the initial function, not every helper on its stack, and Type describes what initiated it. The manual exercises use Editor, not Time Driven or Webapp.

For a trigger failure in an existing automation, identify the trigger's creator as well as its handler. Installable triggers run as their creator, and one account cannot see another account's installed triggers. After repairing a trigger, test its event-independent logic or use a manual-safe runner with sample data; pressing Run does not supply the real event object. Before reinstalling, separately verify the intended trigger owner and the event-specific path. An editor run under your account does not reproduce someone else's permissions. A recorded triggerUid identifies the installed trigger, not an individual event delivery; do not use it to deduplicate events. These are documented trigger restrictions and event fields. This diagnostic project creates no trigger.

When investigating a deployed web app, also record which deployment and version the affected user reached and its execution identity setting. A saved editor change does not update a versioned deployment. Use Deploy > Manage deployments to inspect, not redeploy, the relevant version. The deployment guide distinguishes current head code from a versioned release.

Monitor an automation you own

Execution history answers what happened to one run. Monitoring asks whether the automation is continuing to do its job. Start with the dashboard rather than adding a second logging spreadsheet:

  • Open the project in the dashboard to inspect Executions, Error rate, and Users for a chosen period. Select a deployment tab when investigating that release; ALL also includes editor executions. Error rate is based on uncaught failures, not on whether your business task finished.

  • Star the project and open Starred Projects to view the documented aggregate graphs. Compare failure details and durations before and after a repair. A stable error rate alone can hide repeated audit_skipped or audit_progress outcomes.

  • Check for recent executions when scheduled work appears absent. A lack of failures is not proof the schedule ran. Ask the owner to inspect the relevant trigger and permissions before creating another schedule.

The dashboard documentation defines these metrics and the quota guide describes filtering Running executions to inspect current concurrency. Neither view provides a universal remaining quota balance or a service-level guarantee.

For an installable trigger outside this example, Google documents failure-summary emails with links to the project and trigger settings. These are not an immediate alert for every possible fault, and installable triggers created by add-ons do not send those user notices. Keep an accountable owner checking trigger failures; do not silence the exception to improve the graph or delete the trigger before investigating its unfinished work.

Inspect Cloud logs when you have access

The editor and dashboard are enough for the exercises. Fuller Cloud Logging and Error Reporting require the associated standard Google Cloud project and permission to view its records. Do not change a production project's Cloud association to follow this lesson: switching projects can require reauthorization and other configuration changes.

If your approved test project already has that association and access:

  1. In Apps Script Project Settings, under Google Cloud Project, open the project number.

  2. In the Cloud console, open Logging > Logs Explorer under Operations, and choose a time range containing your run.

  3. Filter by resource.type="app_script_function" and jsonPayload.runId="the-run-id-you-copied", replacing the latter value with the actual ID from your log. Expand a record to inspect its batch fields. If the view is empty, check the selected Cloud project, time range, permissions, and logging configuration before assuming the execution did not happen.

  4. Open Error Reporting to inspect grouped exceptions. Match the example's failing function and stack to the execution rather than treating a grouped error count as a list of audited rows.

The Cloud project guide describes these views. The exception logging guide documents Project Settings > Log uncaught exceptions to Cloud Operations and its Error Reporting integration. This manifest uses "exceptionLogging": "STACKDRIVER", the documented manifest value, despite the newer product names in the UI.

Cloud Logging has its own retention, access rules, and quotas. Logs-based metrics or alerts require separately authorized Cloud configuration; the Apps Script logging interface does not create them for you. A returned Logger.log call does not guarantee log access or notification delivery.

Recover bounded work with a checkpoint

auditRequestBatch reads the header and at most AUDIT_BATCH_SIZE_ request rows, currently 3. It validates and counts the selected rows before attempting to advance REQUEST_TRACKER_AUDIT_NEXT_ROW in script properties. On the final batch it removes that key instead. It never updates Status or Last updated.

With an unchanged four-row fixture and no checkpoint, the expected cycle is:

InvocationRows readBatch countsCheckpoint after the property operation returns
First2 through 4New 1, Ready 1, Hold 1, Done 0String "5"
Second5New 0, Ready 0, Hold 0, Done 1Key removed
Third2 through 4 againNew 1, Ready 1, Hold 1, Done 0String "5" again

Removing the checkpoint restarts the cycle on the next run rather than recording permanent completion. audit_complete means this run validated its selected batch through the observed last row and the removal call returned. It does not prove earlier rows remain valid or that every prior log exists.

The function takes a script lock because the checkpoint is shared state. Without the lock, two executions can read the same next row and overwrite each other's progress. If tryLock(5000) returns false, this invocation logs audit_skipped and does not read or change the checkpoint. The LockService reference scopes that exclusion to cooperating code in one script project. It does not lock out a human editing Sheets, another project, or a manual edit in Script Properties.

Keep the table fixed during an audit cycle. A row-number checkpoint is not a snapshot or stable request ID: sorting, inserting, removing, or changing earlier rows can cause missed or repeated checks. The code rejects malformed checkpoint strings, rows before 2, and a saved row beyond the current table without clearing the key. It cannot detect every edit. After changing the input, stop runs and restart the read-only audit if you need coverage of the revised table.

Interpret the last checkpoint outcome

checkpointOutcome starts as not_attempted. That means this invocation has not attempted a property mutation, not that there is no saved checkpoint. Immediately before saving or removing the key, the code changes the field to unknown; only a returned call changes it to save_returned or remove_returned.

If an error occurs during a property operation, do not state that the old checkpoint survived. The write might have taken effect before its response was lost. Wait until executions are no longer running, inspect only this key in Project Settings > Script Properties, and compare it with the last batch and the input you intended to audit. A failure after a returned property call can leave progress advanced even though the execution is marked Failed.

Because this audit only reads cells, restarting at row 2 repeats checks and consumes quota but does not repeat a send or create. To restart from row 2, wait for any running execution to finish, choose Edit script properties, remove REQUEST_TRACKER_AUDIT_NEXT_ROW, and save.

For a send/create workflow, this reset procedure is not a recovery algorithm. An external action can succeed before its local completion marker fails. Inspect the destination using a stable request or output ID, reconcile the result, and decide what remains before retrying. A lock and checkpoint do not make a transaction across services, and a correlation ID does not provide exactly-once effects.

Choose the right storage scope

Choose a store by the value's lifetime and audience. None of these stores replaces a database.

NeedAppropriate scopeImportant condition
A request-tracker checkpoint shared by its manual runner and triggerScript properties plus a script lockScript properties are shared by users of this script project. They are never shared with another Apps Script project.
A preference for one userUser propertiesOther users of the project do not receive the value.
State tied to a containing documentDocument propertiesAvailable to a script published and executing as an add-on, or a container-bound script in the open file; unavailable contexts return null. The store is private to its script project.
A discardable, short-lived derived valueScript, user, or document cacheA cache read can return null, and a value is not guaranteed to remain until its expiration time. Recompute it when absent.
Prevent two executions from changing shared state togetherScript lockAcquire the lock before reading the shared state and release it in finally.

The Properties service guide defines string-valued script and user stores; the PropertiesService reference specifies document-store availability. The CacheService reference distinguishes the cache scopes and warns that entries are not guaranteed to persist. Do not use a cache as the only checkpoint or source of truth.

Treat quotas as changing constraints

A quota exception is not a request to retry the same large operation immediately. The quota guide says quotas vary by account type, are subject to change, and that exceeding a quota or limitation stops the execution with an exception. Read the current guide for the service and account involved instead of copying a number into a schedule or treating a past limit as a guarantee.

Identify the exact exception, service, execution identity, and last attempted operation. Apps Script daily quotas, runtime and concurrency limits, and a Google or third-party API's own limits are separate constraints. Smaller batches do not reset any of them. Properties reads/writes, new executions, retries, and scheduled runtime also consume resources.

ObservationClassificationRecovery decision
Syntax error while savingCode does not parseRepair the indicated source before running; waiting cannot help.
Invalid header, missing required text, or unsupported statusInput/validation failureRepair the identified sample cell or table shape, then rerun. Do not coerce every bad value into a valid status.
Authorization required or access deniedIdentity, permission, or policy failureCheck the actual actor's access and scopes. A background trigger cannot show a consent prompt. Do not retry on a timer or bypass policy.
Service invoked too many times for a dayDaily service quotaStop the repeated operation and check the current service/account reset rules. An immediate retry spends more runtime without restoring the allowance.
Service invoked too many times in a short timeShort-term rate limitReduce request frequency and batch where supported; use only a bounded, service-appropriate retry policy.
Execution timed out or daily computer-time limitDuration or accumulated runtime budgetInspect the checkpoint and possible effects before resuming. Reduce work and call count; a new trigger does not undo an uncertain action or restore daily runtime.
Server unavailable or a documented retryable HTTP responsePotential transient service failureCheck service status and the provider's guidance. Retry a safe read within a finite budget; reconcile an uncertain write first.
audit_skippedLock contention, not a validation failureLet the other run finish. Repeated skips call for inspecting the overlapping work, not removing the lock.
No completion log after a checkpoint operationUnknown outcomeInspect execution status and stored state after runs stop. Do not infer rollback from missing logs.

Google's troubleshooting guide explains syntax, runtime, authorization, and service errors. The quota exception examples distinguish daily exhaustion from a short burst. Read the complete error, not only a shared prefix.

Bound work and retries separately

The audit's three-row limit bounds input size and per-run row processing, not elapsed time. A service read can be slow; a lock can wait; a property call or logging can fail. Measure normal runs, leave room for progress recording, and reduce the number of service calls before adding a faster schedule. The inspector reads its small table once and computes counts in memory; it does not make a Sheets call for every cell.

When a provider documents retryable reads, choose a finite attempt count and a total elapsed-time budget before the first attempt. Exponential backoff means increasing the delay after each retryable failure, usually with a small random delay called jitter to avoid synchronized clients. For example, a policy could allow three total attempts, delays of about one then two seconds with jitter, and no new attempt after a 20-second budget. These are application choices, not Apps Script or provider quota values. Include setup, request time, response handling, and waiting in that budget. A deadline check cannot interrupt a synchronous service call already in progress.

The Sheets API backoff guidance specifies growing delays with jitter, a delay cap, and an eventual retry limit. It applies to that API; it is not a promise about every SpreadsheetApp exception. This project has no retry loop: stop on a failure and use the evidence to decide the next action.

Use HTTP Retry-After only for an actual response and an operation whose provider documents that retry behavior. HTTP semantics defines either a date or nonnegative seconds; HTTP 429 may include it, but need not. Do not assume a built-in Apps Script service exception contains such a header. If the provider's required wait exceeds your remaining budget, defer rather than shorten that wait. Validation, authentication, permanent failures, and uncertain non-idempotent writes do not become safe to retry because a backoff timer exists.

Exercise: break a later batch, then resume

Start with the original fixture, no running debug session, and no checkpoint. If you previously ran audits, finish the current cycle on unchanged input or use the reset procedure above. Predict the result before each run.

  1. Run auditRequestBatch once. Expect rows 2 through 4, Done count 0, and next row 5. In Project Settings > Script Properties, confirm the checkpoint value is "5"; do not edit it.

  2. Change only D5 from Done to lowercase done in the test spreadsheet. Predict what the inspector and the next audit will do. The inspector reads all four rows; the audit will read only row 5.

  3. Run inspectRequestTracker. Expect Request row 5 has an unsupported status. Use New, Ready, Hold, or Done. The inspector does not alter the checkpoint.

  4. In auditRequestBatch, set a breakpoint on validateDiagnosticRows_(rows, startRow);. Select that entrypoint and click Debug. Expect startRow 5, rowCount 1, rows.length 1, and rows[0][3] equal to 'done'. The script lock is held here; finish this debug run before trying another.

  5. Step in to the validator. Its firstRowNumber is 5; for the first callback, index is 0 and rowNumber is 5. The status lookup returns -1 because comparison is case-sensitive. Resume to let the exception propagate. Expect a failed record at validate_rows, with checkpoint outcome not_attempted, and a Failed execution. No checkpoint operation was reached.

  6. Remove the breakpoint. Once that run has stopped, click Run again without repairing the cell. Predict and observe the same row-5 failure with a different run ID and checkpoint still "5". A rerun alone does not repair input.

  7. Restore D5 to Done, then run auditRequestBatch. Expect only row 5 to be audited, with Done count 1, audit_complete, and the key removed. Inspect the sheet: neither status nor Last updated has been written by the script.

  8. Run the audit again. It starts at row 2 and stores "5", proving that completion was not permanent. One more run finishes that new cycle and removes the key. Run the inspector to recover the original one-of-each summary.

Use Executions to compare the failed row-5 invocation with the repaired one. Explain why the repaired audit checked only one row, why the earlier rows were not revalidated, and why not_attempted did not mean the checkpoint was absent. If your observed input or output differs, stop and inspect it rather than editing the checkpoint to match this table.

Leave the test project idle

Remove breakpoints, restore D5 to Done, and wait for every execution to stop. Finish the two-batch cycle or remove only the audit's checkpoint. Keep the fictional spreadsheet if useful; no file deletion is required. This project has no installed trigger, deployment, sent message, or external output to remove.

If you separately installed the capstone schedule, its owner can run removeRequestTrackerTriggers in the original capstone project when that schedule is no longer wanted. That function does not belong in this diagnostic project. Keep the two projects and their recovery state separate.

Next, move your Apps Script project into a local Git checkout with clasp.

Project files

Complete source files for this chapter’s examples.

Debug a bounded request audit
Code.gs
const DIAGNOSTIC_SHEET_NAME_ = 'Requests';
const DIAGNOSTIC_HEADER_ = ['Request ID', 'Requester', 'Summary', 'Status', 'Last updated'];
const DIAGNOSTIC_STATUSES_ = ['New', 'Ready', 'Hold', 'Done'];
const AUDIT_CHECKPOINT_KEY_ = 'REQUEST_TRACKER_AUDIT_NEXT_ROW';
const AUDIT_BATCH_SIZE_ = 3;
const DIAGNOSTIC_MAX_ROWS_ = 100;

/** @typedef {'New'|'Ready'|'Hold'|'Done'} DiagnosticStatus */
/** @typedef {{runId: string, handler: string, startedAt: number, stage: string, startRow: number|null, endRow: number|null, checkpointOutcome: string}} DiagnosticRun */

/**
 * Logs status counts after validating the Requests table. It does not change cells.
 *
 * @returns {void}
 */
function inspectRequestTracker() {
  const run = beginDiagnosticRun_('inspectRequestTracker');
  try {
    run.stage = 'read_and_validate';
    const rows = readDiagnosticRows_();
    run.stage = 'count_statuses';
    const counts = countStatuses_(rows);
    logDiagnostic_(run, 'inspection_complete',
      'Requests: ' + rows.length + '; New: ' + counts.New + '; Ready: ' + counts.Ready +
      '; Hold: ' + counts.Hold + '; Done: ' + counts.Done,
      { rowCount: rows.length, counts: counts });
  } catch (error) {
    reportDiagnosticFailure_(run);
    throw error;
  }
}

/**
 * Audits a bounded group of rows and stores the next row in script properties.
 * It does not alter spreadsheet cells.
 *
 * @returns {void}
 */
function auditRequestBatch() {
  const run = beginDiagnosticRun_('auditRequestBatch');
  const lock = LockService.getScriptLock();
  let locked = false;
  try {
    run.stage = 'acquire_lock';
    locked = lock.tryLock(5000);
    if (!locked) {
      logDiagnostic_(run, 'audit_skipped',
        'Skipped audit because another execution is updating the checkpoint.', {});
      return;
    }

    run.stage = 'read_header';
    const sheet = getDiagnosticSheet_();
    validateDiagnosticHeader_(sheet.getRange(1, 1, 1, DIAGNOSTIC_HEADER_.length).getValues()[0]);
    const lastRow = sheet.getLastRow();
    run.stage = 'read_checkpoint';
    const properties = PropertiesService.getScriptProperties();
    const checkpoint = properties.getProperty(AUDIT_CHECKPOINT_KEY_);
    const startRow = checkpoint === null ? 2 : Number(checkpoint);
    if (checkpoint !== null && (
      !/^[1-9]\d*$/.test(checkpoint) ||
      !Number.isSafeInteger(startRow) || startRow < 2 || startRow > lastRow
    )) {
      throw new Error(
        'Audit checkpoint is invalid or beyond the current table. Stop runs and reconcile before resetting it.'
      );
    }

    if (lastRow === 1) {
      logDiagnostic_(run, 'audit_empty', 'No request rows to audit; no checkpoint was changed.', {});
      return;
    }

    const rowCount = Math.min(AUDIT_BATCH_SIZE_, lastRow - startRow + 1);
    run.startRow = startRow;
    run.endRow = startRow + rowCount - 1;
    run.stage = 'read_rows';
    const rows = sheet.getRange(startRow, 1, rowCount, DIAGNOSTIC_HEADER_.length).getValues();
    run.stage = 'validate_rows';
    validateDiagnosticRows_(rows, startRow);
    const counts = countStatuses_(rows);
    const nextRow = startRow + rowCount;
    const complete = nextRow > lastRow;

    // A missing response from either property operation leaves its outcome uncertain.
    run.stage = complete ? 'remove_checkpoint' : 'save_checkpoint';
    run.checkpointOutcome = 'unknown';
    if (complete) {
      properties.deleteProperty(AUDIT_CHECKPOINT_KEY_);
      run.checkpointOutcome = 'remove_returned';
    } else {
      properties.setProperty(AUDIT_CHECKPOINT_KEY_, String(nextRow));
      run.checkpointOutcome = 'save_returned';
    }
    run.stage = 'report_batch';
    logDiagnostic_(run, complete ? 'audit_complete' : 'audit_progress',
      'Audited rows ' + startRow + '-' + run.endRow + '. New: ' + counts.New +
      '; Ready: ' + counts.Ready + '; Hold: ' + counts.Hold + '; Done: ' + counts.Done +
      (complete ? '. Audit is complete; checkpoint removal returned.' : '. Next row: ' + nextRow + '.'),
      { rowCount: rowCount, counts: counts, nextRow: complete ? null : nextRow });
  } catch (error) {
    reportDiagnosticFailure_(run);
    throw error;
  } finally {
    if (locked) lock.releaseLock();
  }
}

/**
 * @returns {Array<Array<*>>}
 */
function readDiagnosticRows_() {
  const sheet = getDiagnosticSheet_();
  const lastRow = sheet.getLastRow();
  if (lastRow - 1 > DIAGNOSTIC_MAX_ROWS_) {
    throw new Error('Inspection is limited to 100 request rows. Use auditRequestBatch for bounded reads.');
  }
  const values = sheet.getRange(1, 1, lastRow, DIAGNOSTIC_HEADER_.length).getValues();
  validateDiagnosticHeader_(values[0]);
  const rows = values.slice(1);
  validateDiagnosticRows_(rows, 2);
  return rows;
}

/**
 * @returns {SpreadsheetApp.Sheet}
 */
function getDiagnosticSheet_() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) {
    throw new Error('Open this project from its test spreadsheet with Extensions > Apps Script.');
  }
  const sheet = spreadsheet.getSheetByName(DIAGNOSTIC_SHEET_NAME_);
  if (!sheet) {
    throw new Error('Requests sheet is missing. Import sample.csv into a sheet named Requests.');
  }
  if (sheet.getLastColumn() !== DIAGNOSTIC_HEADER_.length) {
    throw new Error('Requests must contain exactly five used columns, A through E.');
  }
  return sheet;
}

/**
 * @param {Array<*>} header
 * @returns {void}
 */
function validateDiagnosticHeader_(header) {
  if (
    header.length !== DIAGNOSTIC_HEADER_.length ||
    header.some(function (value, index) {
      return value !== DIAGNOSTIC_HEADER_[index];
    })
  ) {
    throw new Error(
      'Requests must use this header row: Request ID, Requester, Summary, Status, Last updated.'
    );
  }
}

/**
 * @param {Array<Array<*>>} rows
 * @param {number} firstRowNumber
 * @returns {void}
 */
function validateDiagnosticRows_(rows, firstRowNumber) {
  rows.forEach(function (row, index) {
    const rowNumber = firstRowNumber + index;
    if (row.length !== DIAGNOSTIC_HEADER_.length) {
      throw new Error('Request row ' + rowNumber + ' must have five columns.');
    }
    for (let column = 0; column < 3; column += 1) {
      if (typeof row[column] !== 'string' || row[column].trim() === '') {
        throw new Error('Request row ' + rowNumber + ' needs text in columns A, B, and C.');
      }
    }
    if (DIAGNOSTIC_STATUSES_.indexOf(row[3]) === -1) {
      throw new Error(
        'Request row ' + rowNumber + ' has an unsupported status. Use New, Ready, Hold, or Done.'
      );
    }
  });
}

/**
 * @param {Array<Array<*>>} rows
 * @returns {{New: number, Ready: number, Hold: number, Done: number}}
 */
function countStatuses_(rows) {
  return rows.reduce(
    function (counts, row) {
      const status = /** @type {DiagnosticStatus} */ (row[3]);
      counts[status] += 1;
      return counts;
    },
    { New: 0, Ready: 0, Hold: 0, Done: 0 }
  );
}

/**
 * @param {string} handler
 * @returns {DiagnosticRun}
 */
function beginDiagnosticRun_(handler) {
  const run = {
    runId: Utilities.getUuid(),
    handler: handler,
    startedAt: Date.now(),
    stage: 'start',
    startRow: null,
    endRow: null,
    checkpointOutcome: 'not_attempted'
  };
  logDiagnostic_(run, 'started', 'Started ' + handler + '.', {});
  return run;
}

/**
 * Only pass bounded, non-personal diagnostic fields, never cell values or errors.
 *
 * @param {DiagnosticRun} run
 * @param {string} event
 * @param {string} message
 * @param {Object} details
 * @returns {void}
 */
function logDiagnostic_(run, event, message, details) {
  Logger.log({
    message: '[' + run.runId + '] ' + message,
    runId: run.runId,
    handler: run.handler,
    event: event,
    stage: run.stage,
    elapsedMs: Date.now() - run.startedAt,
    startRow: run.startRow,
    endRow: run.endRow,
    checkpointOutcome: run.checkpointOutcome,
    ...details
  });
}

/**
 * Add safe context, then let the caller rethrow the original exception and stack.
 *
 * @param {DiagnosticRun} run
 * @returns {void}
 */
function reportDiagnosticFailure_(run) {
  const message = 'Failed at ' + run.stage + '; checkpoint outcome: ' + run.checkpointOutcome +
    '. Inspect the exception before retrying.';
  logDiagnostic_(run, 'failed', message, {});
  console.error('[' + run.runId + '] ' + run.handler + ': ' + message);
}

appsscript.json

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

sample.csv

Download file
sample.csv
Request ID,Requester,Summary,Status,Last updated
R-400,Alex,Review the meeting notes,New,
R-401,Sam,Update the project brief,Ready,2026-09-01T09:00:00Z
R-402,Jo,Prepare a draft agenda,Hold,
R-403,Taylor,Archive approved notes,Done,2026-09-02T14:30:00Z