Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
- [BREAKING] Removed support for running emulators with Java versions prior to 21.
- Add a confirmation in `firebase init dataconnect` before asking for app idea description. (#9282)
- [BREAKING] Removed deprecated `firebase --open-sesame` and `firebase --close-sesame` commands. Use `firebase experiments:enable` and `firebase experiments:disable` instead.
- [BREAKING] Enforce strict timeout validation for functions. (#9540)
102 changes: 102 additions & 0 deletions src/deploy/functions/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,4 +662,106 @@ describe("validate", () => {
}
});
});

describe("validateTimeoutConfig", () => {
const ENDPOINT_BASE: backend.Endpoint = {
platform: "gcfv2",
id: "id",
region: "us-east1",
project: "project",
entryPoint: "func",
runtime: "nodejs16",
httpsTrigger: {},
};

it("should allow valid HTTP v2 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
httpsTrigger: {},
timeoutSeconds: 3600,
};
expect(() => validate.validateTimeoutConfig([ep])).to.not.throw();
});

it("should allow function without timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
httpsTrigger: {},
};
expect(() => validate.validateTimeoutConfig([ep])).to.not.throw();
});

it("should throw on invalid HTTP v2 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
httpsTrigger: {},
timeoutSeconds: 3601,
};
expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError);
});

it("should allow valid Event v2 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
eventTrigger: {
eventType: "google.cloud.storage.object.v1.finalized",
eventFilters: { bucket: "b" },
retry: false,
},
timeoutSeconds: 540,
};
expect(() => validate.validateTimeoutConfig([ep])).to.not.throw();
});

it("should throw on invalid Event v2 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
eventTrigger: {
eventType: "google.cloud.storage.object.v1.finalized",
eventFilters: { bucket: "b" },
retry: false,
},
timeoutSeconds: 541,
};
expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError);
});

it("should allow valid Scheduled v2 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
scheduleTrigger: { schedule: "every 5 minutes" },
timeoutSeconds: 1800,
};
expect(() => validate.validateTimeoutConfig([ep])).to.not.throw();
});

it("should throw on invalid Scheduled v2 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
scheduleTrigger: { schedule: "every 5 minutes" },
timeoutSeconds: 1801,
};
expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError);
});

it("should allow valid Gen 1 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
platform: "gcfv1",
httpsTrigger: {},
timeoutSeconds: 540,
};
expect(() => validate.validateTimeoutConfig([ep])).to.not.throw();
});

it("should throw on invalid Gen 1 timeout", () => {
const ep: backend.Endpoint = {
...ENDPOINT_BASE,
platform: "gcfv1",
httpsTrigger: {},
timeoutSeconds: 541,
};
expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError);
});
});
});
80 changes: 79 additions & 1 deletion src/deploy/functions/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,42 @@ import * as clc from "colorette";
import { FirebaseError } from "../../error";
import { getSecretVersion, SecretVersion } from "../../gcp/secretManager";
import { logger } from "../../logger";
import { getFunctionLabel } from "./functionsDeployHelper";
import { serviceForEndpoint } from "./services";
import * as fsutils from "../../fsutils";
import * as backend from "./backend";
import * as utils from "../../utils";
import * as secrets from "../../functions/secrets";
import { serviceForEndpoint } from "./services";

/**
* GCF Gen 1 has a max timeout of 540s.
*/
const MAX_V1_TIMEOUT_SECONDS = 540;

/**
* Eventarc triggers are implicitly limited by Pub/Sub's ack deadline (600s).
* However, GCFv2 API prevents creation of functions with timeout > 540s.
* See https://cloud.google.com/pubsub/docs/subscription-properties#ack_deadline
*/
const MAX_V2_EVENTS_TIMEOUT_SECONDS = 540;

/**
* Cloud Scheduler has a max attempt deadline of 30 minutes.
* See https://cloud.google.com/scheduler/docs/reference/rest/v1/projects.locations.jobs#Job.FIELDS.attempt_deadline
*/
const MAX_V2_SCHEDULE_TIMEOUT_SECONDS = 1800;

/**
* Cloud Tasks has a max dispatch deadline of 30 minutes.
* See https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#Task.FIELDS.dispatch_deadline
*/
const MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS = 1800;

/**
* HTTP and Callable functions have a max timeout of 60 minutes.
* See https://cloud.google.com/run/docs/configuring/request-timeout
*/
const MAX_V2_HTTP_TIMEOUT_SECONDS = 3600;

function matchingIds(
endpoints: backend.Endpoint[],
Expand All @@ -32,6 +63,7 @@ const cpu = (endpoint: backend.Endpoint): number => {
export function endpointsAreValid(wantBackend: backend.Backend): void {
const endpoints = backend.allEndpoints(wantBackend);
functionIdsAreValid(endpoints);
validateTimeoutConfig(endpoints);
for (const ep of endpoints) {
serviceForEndpoint(ep).validateTrigger(ep, wantBackend);
}
Expand Down Expand Up @@ -145,6 +177,52 @@ export function cpuConfigIsValid(endpoints: backend.Endpoint[]): void {
}
}

/**
* Validates that the timeout for each endpoint is within acceptable limits.
* This is a breaking change to prevent dangerous infinite retry loops and confusing timeouts.
*/
export function validateTimeoutConfig(endpoints: backend.Endpoint[]): void {
const invalidEndpoints: { ep: backend.Endpoint; limit: number }[] = [];
for (const ep of endpoints) {
const timeout = ep.timeoutSeconds;
if (!timeout) {
continue;
}

let limit: number | undefined;
if (ep.platform === "gcfv1") {
limit = MAX_V1_TIMEOUT_SECONDS;
} else if (backend.isEventTriggered(ep)) {
limit = MAX_V2_EVENTS_TIMEOUT_SECONDS;
} else if (backend.isScheduleTriggered(ep)) {
limit = MAX_V2_SCHEDULE_TIMEOUT_SECONDS;
} else if (backend.isTaskQueueTriggered(ep)) {
limit = MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS;
} else if (backend.isHttpsTriggered(ep) || backend.isCallableTriggered(ep)) {
limit = MAX_V2_HTTP_TIMEOUT_SECONDS;
}

if (limit !== undefined && timeout > limit) {
invalidEndpoints.push({ ep, limit });
}
}

if (invalidEndpoints.length === 0) {
return;
}

const invalidList = invalidEndpoints
.sort((a, b) => backend.compareFunctions(a.ep, b.ep))
.map(({ ep, limit }) => `\t${getFunctionLabel(ep)}: ${ep.timeoutSeconds}s (limit: ${limit}s)`)
.join("\n");

const msg =
"The following functions have timeouts that exceed the maximum allowed for their trigger type:\n\n" +
invalidList +
"\n\nFor more information, see https://firebase.google.com/docs/functions/quotas#time_limits";
throw new FirebaseError(msg);
}

/** Validate that all endpoints in the given set of backends are unique */
export function endpointsAreUnique(backends: Record<string, backend.Backend>): void {
const endpointToCodebases: Record<string, Set<string>> = {}; // function name -> codebases
Expand Down
Loading