Debug executions and recover from limits
Debug Google Apps Script with breakpoints, Logger.log, and execution history, then resume a read-only batch audit from its checkpoint after a failure.
Find a failing input with the debugger, group logs by run ID, and decide whether to repair the input, resume the audit, or first check what already happened.
Find the input that caused a failure before you change the script or run it again. Step through a status count in the debugger, trace a failed batch through its logs, and resume a read-only audit from its checkpoint. You need 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 practice spreadsheet and the sample data. Do not replace the capstone's code or import this sample data into its working table.
Create a spreadsheet and name a tab
Requests.Copy the
sample.csvtext shown below intoA1. 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 occupyA1:E5, with nothing outside those five columns.Open Extensions > Apps Script from this spreadsheet. In Project Settings, enable Show "appsscript.json" manifest file in editor, then return to Editor.
Replace the new project's default
Code.gswith the complete file below and replaceappsscript.jsonwith the included manifest. Save both files. KeepV8,Etc/UTC, and the two explicit scopes. No library, advanced service, spreadsheet ID, credential, deployment, or trigger is needed.Select
inspectRequestTrackerin the function selector and click Run. Review and approve the authorization prompt with the account that owns this practice spreadsheet.
Run and Debug execute as the user who clicks them, with that user's authorization. The manifest's spreadsheets.currentonly scope allows access to the current spreadsheet, including edits, although this code only reads cells. script.storage allows the project to store properties. inspectRequestTracker changes no property, and auditRequestBatch stores or removes one named checkpoint. Both write logs. The authorization guide explains which account a script runs as and what its scopes allow.
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
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);
}
{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"exceptionLogging": "STACKDRIVER",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets.currentonly",
"https://www.googleapis.com/auth/script.storage"
]
}
Open Execution log. After a start message, the inspector's expected summary is the following, prefixed with a generated run ID:
Requests: 4; New: 1; Ready: 1; Hold: 1; Done: 1 inspectRequestTracker checks the sheet name, the five used columns, the exact header order, nonblank text in the first three columns, and the four recognized statuses. Before reading the table, it refuses more than 100 request rows. That limit belongs to this example. It is not a Google quota. auditRequestBatch applies the same row checks only to its next three rows, so it can hit a bad row in a later batch after earlier batches passed.
The audit checks table structure and status names only. It does not check unique IDs, timestamps, formulas, merged cells, protected ranges, or status transitions. The sample data includes one row in each status so you can count them without running the capstone's writer.
Follow a count through the debugger
A breakpoint pauses the script before a selected line runs. Use it to compare what the code is about to do with what you predicted, instead of logging whole rows. To add one, hover over a line number and click the circle to its left. Start the run with Debug instead of Run. 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:
In
inspectRequestTracker, findconst 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, so make sure you choose the inspector's line.Add a second breakpoint on
counts[status] += 1;insidecountStatuses_.Select
inspectRequestTrackerand click Debug. At the first pause, expandrowsin the Debugger panel. Its length should be 4 androws[0][3]should be'New'.run.stageis'count_statuses'. The localcountshas not been assigned yet because its line has not run.Click Step in. The debugger enters
countStatuses_and pauses atreturn rows.reduce(. Click Step over once. It callsreduceand stops at the second breakpoint before the first increment, without making you step into the internals ofreduce.At this pause,
statusshould be'New', and the accumulatorcountsshould contain four zeroes. Click Step over once.counts.Newbecomes 1 while the other counts stay 0.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.
rows[0] is spreadsheet row 2 because readDiagnosticRows_ has already removed the header. This offset 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 stays 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, an ID that groups the messages from one invocation. It appears in square brackets in every message and as a separate structured field. A second invocation gets a new ID, even when it repeats the same failed batch. The runId is not the execution ID shown in Executions, and it does not stop the script from doing the same work twice.
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 Execution log displays every field.
| Field | Question it answers |
|---|---|
runId, handler | Which invocation and entrypoint produced this message? |
event | Did it start, finish inspection, advance an audit, finish an audit, skip a lock, or fail? |
stage | Was it reading rows, validating them, or attempting a checkpoint operation? |
startRow, endRow | Which spreadsheet rows were involved in this batch? null means no batch was selected. |
counts, rowCount | What did this inspection or batch contain? These are not cumulative audit totals. |
nextRow | Where should the next batch start? It is null on the last batch. |
checkpointOutcome | Was a property change not attempted, uncertain, or observed to return? |
elapsedMs | How much wall-clock time passed since this invocation created its diagnostic context? |
When an entrypoint catches a failure, reportDiagnosticFailure_ logs the run's diagnostic fields (run ID, stage, and row numbers) through Logger and writes a text console.error message. The caller then rethrows the original exception, so the failed execution and its stack trace stay visible. Logging an error and returning normally would hide the failure from the 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.
The unsupported-status error names the row and the allowed statuses without echoing the bad cell, because an invalid status cell could contain an email address or a pasted token. Do not log whole event objects, request or response bodies, authorization headers, property stores, or exception objects from external APIs. Even built-in service exceptions can contain sensitive context, so inspect and redact them before sharing. A timeout can stop execution before the catch block or final log runs, so a missing completion message does not mean that nothing happened. Check Executions.
Find a past execution
The logging guide calls Execution log a short-lived development stream. It follows the current editor run or debug session. For a past failure:
Open the project's Executions page.
Choose Add a filter > Status and select Failed and Timed out.
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.
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 a My Executions page. It lists 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 started the run. In the manual exercises, Type is Editor.
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 the code, test the parts that do not need an event. To test the handler itself, call it from another function with sample event data, because clicking Run does not supply the real event object. An editor run under your account does not reproduce someone else's permissions, so before reinstalling, confirm the intended trigger owner separately.
A recorded triggerUid identifies the installed trigger. Multiple events from that trigger share the ID, so it cannot detect duplicate deliveries. See the 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 the relevant version without redeploying it. The deployment guide distinguishes current head code from a versioned release.
Monitor an automation you own
Use Executions to investigate one run. Use the dashboard to check whether the automation keeps doing its job. Start with the dashboard before you consider a separate 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 counts uncaught failures. It does not tell you 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_skippedoraudit_progressoutcomes.Check for recent executions when scheduled work appears absent. A run that never started does not appear as a failure. 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. No view shows how much quota remains, and neither view is 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 emails do not alert you immediately to every possible fault, and installable triggers created by add-ons do not send them. Make one owner responsible for checking trigger failures. Do not silence the exception to improve the graph, and do not 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, because switching projects can require reauthorization and other configuration changes.
If your approved test project already has that association and access:
In Apps Script Project Settings, under Google Cloud Project, open the project number.
In the Cloud console, open Logging > Logs Explorer under Operations, and choose a time range containing your run.
Filter by
resource.type="app_script_function"andjsonPayload.runId="the-run-id-you-copied", replacing the placeholder with the run 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. An empty view does not show that the execution did not happen.Open Error Reporting to inspect grouped exceptions. Match the example's failing function and stack to the execution. A grouped error count is not 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.
Resume a batch job from a checkpoint
auditRequestBatch reads the header and at most AUDIT_BATCH_SIZE_ request rows, currently 3. It validates and counts the selected rows, then saves the next row number in the script property REQUEST_TRACKER_AUDIT_NEXT_ROW. On the final batch it removes that key instead. It never updates Status or Last updated.
With the unchanged four-row sample data and no checkpoint, the expected cycle is:
| Invocation | Rows read | Batch counts | Checkpoint after the property operation returns |
|---|---|---|---|
| First | 2 through 4 | New 1, Ready 1, Hold 1, Done 0 | String "5" |
| Second | 5 | New 0, Ready 0, Hold 0, Done 1 | Key removed |
| Third | 2 through 4 again | New 1, Ready 1, Hold 1, Done 0 | String "5" again |
After the checkpoint is removed, the next run starts the cycle again. Nothing records that the audit is permanently complete. audit_complete means this run validated its selected batch through the observed last row and the removal call returned. It does not show that earlier rows are still valid or that every earlier 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. tryLock(5000) waits up to five seconds for the lock. If it returns false, this invocation logs audit_skipped and does not read or change the checkpoint. The lock blocks only other code in this script project that also takes the lock (see the LockService reference). It does not lock out a person editing Sheets, another project, or a manual edit in Script Properties.
Keep the table fixed during an audit cycle. A row-number checkpoint records only a position in the table. 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. It does not clear the key when it rejects one. It cannot detect every edit. If you change the input and need the audit to cover the revised table, stop runs and restart the read-only audit.
Interpret the last checkpoint outcome
checkpointOutcome starts as not_attempted. That value means this invocation has not tried to change the property. An earlier run may still have saved a checkpoint. Right before saving or removing the key, the code sets the field to unknown. Only a property call that returns changes it to save_returned or remove_returned.
If an error occurs during a property operation, do not assume that the old checkpoint survived. The write might have taken effect before its response was lost. Wait until no execution is 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 it does not send or create anything again. To restart from row 2, wait for any running execution to finish, choose Edit script properties, remove REQUEST_TRACKER_AUDIT_NEXT_ROW, and save.
Do not use this reset for a workflow that sends or creates things. An external action can succeed even if the script then fails to save its local completion marker. Before retrying, inspect the destination using a stable request or output ID, compare it with what the script recorded, and decide what remains. A lock and checkpoint cannot make steps in different services succeed or fail together, and a correlation ID does not stop an action from happening twice.
Choose the right storage scope
Choose a store by how long the value must last and who needs to read it. None of these stores replaces a database.
| Need | Appropriate scope | Important condition |
|---|---|---|
| A request-tracker checkpoint shared by its manual runner and trigger | Script properties plus a script lock | Script properties are shared by users of this script project. They are never shared with another Apps Script project. |
| A preference for one user | User properties | Other users of the project do not receive the value. |
| State tied to a containing document | Document properties | Available to a script published and executing as an add-on, or a container-bound script in the open file. Other contexts return null. The store is private to its script project. |
| A discardable, short-lived derived value | Script, user, or document cache | A 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 together | Script lock | Acquire 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. Keep checkpoints and authoritative data in persistent storage, not only in a cache.
Treat quotas as changing constraints
When you hit a quota, do not rerun the same large operation right away. The quota guide says quotas vary by account type and can change, and that exceeding a quota or limit stops the execution with an exception. Read the current guide for the service and account involved. Do not copy a number into a schedule or rely on a limit you saw in the past.
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. Property reads and writes, new executions, retries, and scheduled runtime also use these resources.
| Observation | Classification | Recovery decision |
|---|---|---|
| Syntax error while saving | Code does not parse | Repair the indicated source before running. Waiting cannot help. |
| Invalid header, missing required text, or unsupported status | Input validation failure | Repair the identified sample cell or table shape, then rerun. Do not coerce every bad value into a valid status. |
| Authorization required or access denied | Identity, permission, or policy failure | Check 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 day | Daily service quota | Stop the repeated operation and check the current reset rules for the service and account. An immediate retry spends more runtime without restoring the allowance. |
Service invoked too many times in a short time | Short-term rate limit | Reduce request frequency and batch where supported. If you retry, use a limited number of attempts that suits the service. |
| Execution timed out or daily computer-time limit | Duration or accumulated runtime budget | Inspect 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 response | Potential transient service failure | Check service status and the provider's guidance. Retry a safe read within a finite budget. Before retrying an uncertain write, check whether it already happened. |
audit_skipped | Lock contention, not a validation failure | Let the other run finish. If skips repeat, inspect the overlapping work. Do not remove the lock. |
| No completion log after a checkpoint operation | Unknown outcome | Inspect 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. For common syntax errors when saving, see SyntaxError: missing ) after argument list and SyntaxError: unterminated string literal. The quota exception examples distinguish daily exhaustion from a short burst. Compare the full error message, because the daily and short-term messages begin with the same words.
Limit batch size and retries separately
The audit's three-row limit caps the rows processed per run. It does not limit elapsed time: service reads can be slow, lock waits take time, and property calls or logging can fail. Measure normal runs, leave room for recording progress, and reduce the number of service calls before adding a faster schedule. The inspector reads its small table in one call and counts in memory instead of making a Sheets call for every cell.
When a provider documents that a read can be retried, choose the number of attempts and a total time budget before the first attempt. Exponential backoff means waiting longer after each failed attempt, usually with a small random extra delay called jitter so that many clients do not retry at the same moment. For example, you could choose:
At most three attempts.
Delays of about one and then two seconds, with jitter.
No new attempt after 20 seconds.
These are policy choices, not Apps Script or provider quota values. The time budget includes setup, requests, response handling, and waiting. A deadline check cannot interrupt a synchronous service call that is already running.
The Sheets API backoff guidance specifies growing delays with jitter, a delay cap, and an eventual retry limit. It applies to that API. It does not describe every SpreadsheetApp exception. This project has no retry loop. It stops on a failure so that you can read the logs before deciding what to do next.
Use an HTTP Retry-After header only when you have a real HTTP response from a provider that documents that retry behavior. HTTP semantics defines its value as either a date or a nonnegative number of seconds. An HTTP 429 response may include it but need not. A built-in Apps Script service exception has no such header. If the provider's required wait is longer than your remaining budget, postpone the work instead of shortening the wait. Backoff does not make it safe to retry a validation failure, an authentication failure, a permanent failure, or a write that may already have happened.
Exercise: break a later batch, then resume
Start with the original sample data, no running debug session, and no checkpoint. If you ran audits earlier, finish the current cycle on unchanged input or use the reset procedure above. Predict the result before each run.
Run
auditRequestBatchonce. Expect rows 2 through 4,Donecount 0, and next row 5. In Project Settings > Script Properties, confirm the checkpoint value is"5". Do not edit it.Change only
D5fromDoneto lowercasedonein the practice spreadsheet. Predict what the inspector and the next audit will do. The inspector reads all four rows. The audit will read only row 5.Run
inspectRequestTracker. ExpectRequest row 5 has an unsupported status. Use New, Ready, Hold, or Done.The inspector does not alter the checkpoint.In
auditRequestBatch, set a breakpoint onvalidateDiagnosticRows_(rows, startRow);. Select that entrypoint and click Debug. ExpectstartRow5,rowCount1,rows.length1, androws[0][3]equal to'done'. The script lock is held here, so finish this debug run before trying another.Step in to the validator. Its
firstRowNumberis 5. For the first callback,indexis 0 androwNumberis 5. The status lookup returns -1 because comparison is case-sensitive. Resume to let the exception propagate. Expect afailedrecord atvalidate_rows, with checkpoint outcomenot_attempted, and a Failed execution. No checkpoint operation was reached.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".Restore
D5toDone, then runauditRequestBatch. Expect only row 5 to be audited, withDonecount 1,audit_complete, and the key removed. Inspect the sheet: neither status norLast updatedhas been written by the script.Run the audit again. It starts again at row 2 and stores
"5". Completion was not recorded permanently. One more run finishes that new cycle and removes the key. Run the inspector to see the original one-of-each summary again.
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. Do not edit the checkpoint to match this table.
Leave the test project idle
Remove the 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 it is useful. Nothing needs to be deleted. 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.
Next, read Local Apps Script development with clasp.
Project files
Complete source files for this chapter's examples.
Debug a bounded request audit
Code.gs
Download fileconst 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{
"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 fileRequest 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