From d99cddcb5247b39efef068583c9838f6dd338fb6 Mon Sep 17 00:00:00 2001 From: Immanuel Pelzer Date: Wed, 29 Jul 2026 16:48:46 +0200 Subject: [PATCH 1/2] feat(constructs): derive response time limits from account features The response time limit caps for TCP, API and URL checks are hardcoded, so an account entitled to higher limits still has its checks rejected locally before the request reaches the API. Read the account's client-visible feature flags: entitled accounts skip the client-side cap entirely, deferring to the API, which owns the actual limit and can change it without a CLI release. The account is already fetched to validate credentials, so no additional request is made. Standard limits apply when the flags are absent, which keeps older and self-hosted deployments working. --- packages/cli/src/commands/authCommand.ts | 4 + .../__tests__/account-features.spec.ts | 122 ++++++++++++++++++ packages/cli/src/constructs/api-check.ts | 6 +- .../constructs/internal/account-features.ts | 27 ++++ .../constructs/internal/common-diagnostics.ts | 2 +- packages/cli/src/constructs/session.ts | 2 + packages/cli/src/constructs/tcp-monitor.ts | 6 +- packages/cli/src/constructs/url-monitor.ts | 6 +- packages/cli/src/rest/accounts.ts | 5 + 9 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/account-features.spec.ts create mode 100644 packages/cli/src/constructs/internal/account-features.ts diff --git a/packages/cli/src/commands/authCommand.ts b/packages/cli/src/commands/authCommand.ts index 6e58e1fc1..d93a0acaf 100644 --- a/packages/cli/src/commands/authCommand.ts +++ b/packages/cli/src/commands/authCommand.ts @@ -2,6 +2,7 @@ import prompts from 'prompts' import { BaseCommand } from './baseCommand.js' import * as api from '../rest/api.js' import { Account } from '../rest/accounts.js' +import { Session } from '../constructs/session.js' import { detectCliMode } from '../helpers/cli-mode.js' import type { CommandPreview } from '../helpers/command-preview.js' import { formatPreviewForAgent, formatPreviewForTerminal } from '../helpers/command-preview.js' @@ -22,6 +23,9 @@ export abstract class AuthCommand extends BaseCommand { protected async init (): Promise { await super.init() this.#account = await api.validateAuthentication() + // Constructs validate against account-specific limits and have no access to + // the command instance. + Session.accountFeatures = this.#account?.features ?? [] } protected async confirmOrAbort ( diff --git a/packages/cli/src/constructs/__tests__/account-features.spec.ts b/packages/cli/src/constructs/__tests__/account-features.spec.ts new file mode 100644 index 000000000..5aa6375f5 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/account-features.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, afterEach } from 'vitest' + +import { ApiCheck, TcpMonitor, TcpRequest, UrlMonitor } from '../index.js' +import { Diagnostics } from '../diagnostics.js' +import { Project } from '../project.js' +import { Session } from '../session.js' + +const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS' + +const tcpRequest: TcpRequest = { + hostname: 'acme.com', + port: 443, +} + +const newProject = () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) +} + +const validate = async (construct: { validate: (diagnostics: Diagnostics) => Promise }) => { + const diagnostics = new Diagnostics() + await construct.validate(diagnostics) + return diagnostics +} + +describe('account-aware response time limits', () => { + afterEach(() => { + Session.accountFeatures = [] + }) + + it('rejects an extended response time without the feature', async () => { + newProject() + Session.accountFeatures = [] + + const diagnostics = await validate(new TcpMonitor('tcp-standard', { + name: 'Test Check', + request: tcpRequest, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be 5000 or lower'), + }), + ])) + }) + + it('accepts an extended response time with the feature', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new TcpMonitor('tcp-extended', { + name: 'Test Check', + request: tcpRequest, + degradedResponseTime: 40_000, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('defers values above 45s to the API with the feature', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new TcpMonitor('tcp-over-extended', { + name: 'Test Check', + request: tcpRequest, + maxResponseTime: 300_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('keeps the degraded <= max cross-check with the feature', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new TcpMonitor('tcp-degraded-above-max', { + name: 'Test Check', + request: tcpRequest, + degradedResponseTime: 46_000, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be less than or equal to "maxResponseTime"'), + }), + ])) + }) + + it('raises the API check limit above its 30s standard', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new ApiCheck('api-extended', { + name: 'Test Check', + request: { url: 'https://acme.com', method: 'GET' }, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('raises the URL monitor limit above its 30s standard', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new UrlMonitor('url-extended', { + name: 'Test Check', + request: { url: 'https://acme.com' }, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) +}) diff --git a/packages/cli/src/constructs/api-check.ts b/packages/cli/src/constructs/api-check.ts index 06fd0ea4f..790d2eee8 100644 --- a/packages/cli/src/constructs/api-check.ts +++ b/packages/cli/src/constructs/api-check.ts @@ -10,6 +10,7 @@ import { Diagnostics } from './diagnostics.js' import { DeprecatedPropertyDiagnostic, InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' import { ApiCheckBundle, ApiCheckBundleProps } from './api-check-bundle.js' import { Assertion } from './api-assertion.js' +import { responseTimeLimits } from './internal/account-features.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { Bundler } from '../services/check-parser/bundler.js' @@ -257,10 +258,7 @@ export class ApiCheck extends RuntimeCheck { } } - await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, - }) + await validateResponseTimes(diagnostics, this, responseTimeLimits(30_000)) } async bundle (bundler: Bundler): Promise { diff --git a/packages/cli/src/constructs/internal/account-features.ts b/packages/cli/src/constructs/internal/account-features.ts new file mode 100644 index 000000000..03961b671 --- /dev/null +++ b/packages/cli/src/constructs/internal/account-features.ts @@ -0,0 +1,27 @@ +import { Session } from '../session.js' +import type { ResponseTimeLimits } from './common-diagnostics.js' + +// Raises the response time limits the API accepts for TCP, API and URL checks. +const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS' + +/** + * Response time limits for the authenticated account. + * + * Accounts with extended limits skip the client-side cap entirely: the exact + * limit lives server-side and can change without a CLI release, so the CLI + * defers to the API instead of hardcoding a ceiling. The degraded <= max + * cross-check still applies. + * + * Falls back to the standard limit when the account is not entitled, and when + * the API does not report features at all (older or self-hosted deployments). + */ +export function responseTimeLimits (standard: number): ResponseTimeLimits { + const limit = Session.accountFeatures.includes(EXTENDED_RESPONSE_TIME_LIMITS) + ? Number.MAX_SAFE_INTEGER + : standard + + return { + degradedResponseTime: limit, + maxResponseTime: limit, + } +} diff --git a/packages/cli/src/constructs/internal/common-diagnostics.ts b/packages/cli/src/constructs/internal/common-diagnostics.ts index 52a5fcdf5..e97c45ac9 100644 --- a/packages/cli/src/constructs/internal/common-diagnostics.ts +++ b/packages/cli/src/constructs/internal/common-diagnostics.ts @@ -126,7 +126,7 @@ type ResponseTimeProps = { maxResponseTime?: number } -type ResponseTimeLimits = { +export type ResponseTimeLimits = { degradedResponseTime: number maxResponseTime: number // The per-type default that the backend applies to `maxResponseTime` when the diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index 94bc6a398..1af9d900f 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -66,6 +66,7 @@ export class Session { static verifyRuntimeDependencies = true static loadingChecklyConfigFile: boolean static checklyConfigFileConstructs?: Construct[] + static accountFeatures: string[] = [] static privateLocations: PrivateLocationApi[] static parsers = new Map() static playwrightProjectBundler?: PlaywrightProjectBundler @@ -91,6 +92,7 @@ export class Session { this.verifyRuntimeDependencies = true this.loadingChecklyConfigFile = false this.checklyConfigFileConstructs = undefined + this.accountFeatures = [] this.privateLocations = [] this.parsers = new Map() this.playwrightProjectBundler = undefined diff --git a/packages/cli/src/constructs/tcp-monitor.ts b/packages/cli/src/constructs/tcp-monitor.ts index 599b63f24..c821eed02 100644 --- a/packages/cli/src/constructs/tcp-monitor.ts +++ b/packages/cli/src/constructs/tcp-monitor.ts @@ -3,6 +3,7 @@ import { IPFamily } from './ip.js' import { Session } from './session.js' import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion.js' import { Diagnostics } from './diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' import { validateResponseTimes } from './internal/common-diagnostics.js' type TcpAssertionSource = 'RESPONSE_DATA' | 'RESPONSE_TIME' @@ -158,10 +159,7 @@ export class TcpMonitor extends Monitor { async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) - await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 5_000, - maxResponseTime: 5_000, - }) + await validateResponseTimes(diagnostics, this, responseTimeLimits(5_000)) } synthesize () { diff --git a/packages/cli/src/constructs/url-monitor.ts b/packages/cli/src/constructs/url-monitor.ts index 271a9918f..6e64d3624 100644 --- a/packages/cli/src/constructs/url-monitor.ts +++ b/packages/cli/src/constructs/url-monitor.ts @@ -1,4 +1,5 @@ import { Diagnostics } from './diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { Monitor, MonitorProps } from './monitor.js' import { Session } from './session.js' @@ -139,10 +140,7 @@ export class UrlMonitor extends Monitor { async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) - await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, - }) + await validateResponseTimes(diagnostics, this, responseTimeLimits(30_000)) } synthesize () { diff --git a/packages/cli/src/rest/accounts.ts b/packages/cli/src/rest/accounts.ts index eff33dc87..13326d1ef 100644 --- a/packages/cli/src/rest/accounts.ts +++ b/packages/cli/src/rest/accounts.ts @@ -12,6 +12,11 @@ export interface Account { plan?: string planDisplayName?: string addons?: Record + /** + * Client-visible account feature flags. Absent when the API predates them, + * in which case standard limits apply. + */ + features?: string[] } class Accounts { From 7384c89dcd08d47abdfce8ede2742544284e98da Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Sat, 1 Aug 2026 01:00:55 +0900 Subject: [PATCH 2/2] fix: use flexible response time limits in every check type that validates them --- packages/cli/src/constructs/dns-monitor.ts | 4 ++-- packages/cli/src/constructs/grpc-monitor.ts | 4 ++-- packages/cli/src/constructs/ssl-monitor.ts | 4 ++-- packages/cli/src/constructs/traceroute-monitor.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/constructs/dns-monitor.ts b/packages/cli/src/constructs/dns-monitor.ts index af95d56be..2fcd43e06 100644 --- a/packages/cli/src/constructs/dns-monitor.ts +++ b/packages/cli/src/constructs/dns-monitor.ts @@ -4,6 +4,7 @@ import { Diagnostics } from './diagnostics.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { DnsRequest } from './dns-request.js' import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' export interface DnsMonitorProps extends MonitorProps { /** @@ -99,8 +100,7 @@ export class DnsMonitor extends Monitor { } await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 5_000, - maxResponseTime: 5_000, + ...responseTimeLimits(5_000), }) } diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts index 0cb90931a..a197fb7df 100644 --- a/packages/cli/src/constructs/grpc-monitor.ts +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -4,6 +4,7 @@ import { Diagnostics } from './diagnostics.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { validateGrpcAssertion } from './grpc-assertion-validation.js' import { GrpcRequest } from './grpc-request.js' +import { responseTimeLimits } from './internal/account-features.js' export interface GrpcMonitorProps extends MonitorProps { /** @@ -79,8 +80,7 @@ export class GrpcMonitor extends Monitor { await validateResponseTimes(diagnostics, this, { // gRPC allows thresholds up to 180s (calls can run to the 180s timeout), // matching the backend's gRPC response-time limits. - degradedResponseTime: 180_000, - maxResponseTime: 180_000, + ...responseTimeLimits(180_000), // Backend default applied when maxResponseTime is omitted. defaultMaxResponseTime: 20_000, }) diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index 438755b7a..ef34379a2 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -5,6 +5,7 @@ import { SslRequest } from './ssl-request.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { validateSslAssertion } from './ssl-assertion-validation.js' import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' export interface SslMonitorProps extends MonitorProps { /** @@ -81,8 +82,7 @@ export class SslMonitor extends Monitor { } await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, + ...responseTimeLimits(30_000), // Backend default applied when maxResponseTime is omitted. defaultMaxResponseTime: 10_000, }) diff --git a/packages/cli/src/constructs/traceroute-monitor.ts b/packages/cli/src/constructs/traceroute-monitor.ts index 8c8fda577..0884bbd0f 100644 --- a/packages/cli/src/constructs/traceroute-monitor.ts +++ b/packages/cli/src/constructs/traceroute-monitor.ts @@ -4,6 +4,7 @@ import { Diagnostics } from './diagnostics.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { validateTracerouteAssertion } from './traceroute-assertion-validation.js' import { TracerouteRequest } from './traceroute-request.js' +import { responseTimeLimits } from './internal/account-features.js' export interface TracerouteMonitorProps extends MonitorProps { /** @@ -77,8 +78,7 @@ export class TracerouteMonitor extends Monitor { await super.validate(diagnostics) await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, + ...responseTimeLimits(30_000), // Backend default applied when maxResponseTime is omitted. defaultMaxResponseTime: 20_000, })