Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Generate, preview, and upgrade data apps with data-app 0.74.0.
- Raise the default `--connect-timeout` from 5 to 30 seconds, so queries that take a few seconds to start responding no longer time out.
- Timeout errors now name the CLI flag that fired, explain that the limit is client-side (the request may still be running on the server, and slow networks can cause it), and say how to raise it.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Altertable CLI v1.13.2 • Query and manage your data platform from the terminal
| `--no-agent` | Disable agent mode, including automatic detection |
| `--no-color` | Disable terminal colors and styling |
| `--profile <PROFILE>` | Use a named profile for this command only |
| `--connect-timeout <CONNECT-TIMEOUT>` | HTTP connect timeout in seconds (default 5) |
| `--connect-timeout <CONNECT-TIMEOUT>` | HTTP connect timeout in seconds (default 30) |
| `--read-timeout <READ-TIMEOUT>` | HTTP read timeout in seconds (default 60; 0 = no limit for streams) |
| `--ignore-ssl-errors` | Skip TLS certificate verification for HTTPS requests (for development only, e.g. self-signed local servers) |

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ These flags apply to every command and may be placed before or after commands:
| `--no-agent` | Disable agent mode, including automatic detection |
| `--agent` | Agent preset: structured JSON output, no pager, colors, or terminal styling |
| `--debug`, `-d` | Enable debug output |
| `--connect-timeout <s>` | HTTP connect timeout in seconds (default: `5`) |
| `--connect-timeout <s>` | HTTP connect timeout in seconds (default: `30`) |
| `--read-timeout <s>` | HTTP read timeout in seconds (default: `60`; `0` = unlimited for streams) |

Per-request read timeout on `query`, `upload`, and `upsert`:
Expand Down
2 changes: 1 addition & 1 deletion cli-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
"name": "connect-timeout",
"aliases": [],
"type": "string",
"description": "HTTP connect timeout in seconds (default 5)",
"description": "HTTP connect timeout in seconds (default 30)",
"required": false,
"repeatable": false,
"scope": "global",
Expand Down
2 changes: 1 addition & 1 deletion cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const ROOT_ARGS = defineArguments({
},
'connect-timeout': {
type: 'string',
description: 'HTTP connect timeout in seconds (default 5)',
description: 'HTTP connect timeout in seconds (default 30)',
flagScope: 'global',
},
'read-timeout': {
Expand Down
47 changes: 44 additions & 3 deletions cli/src/lib/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
shouldIgnoreSslErrors,
} from '@/lib/http.ts';
import { redactResponseBodyForDebug } from '@/lib/redact.ts';
import { DEFAULT_CONNECT_TIMEOUT_MS } from '@/lib/transport-defaults.ts';
import { delay } from '@/test-utils/time.ts';

const fakeBearerKey = 'atm_fake_test_key_for_redaction';
Expand Down Expand Up @@ -328,7 +329,7 @@ describe('context timeout resolution', () => {
url: 'https://example.com',
authHeader: 'Authorization: Bearer test',
})
).toBe(5_000 + 30_000);
).toBe(DEFAULT_CONNECT_TIMEOUT_MS + 30_000);
});

test('stream read timeout 0 uses connect-only abort when context unset', () => {
Expand All @@ -350,7 +351,7 @@ describe('context timeout resolution', () => {
authHeader: 'Authorization: Bearer test',
readTimeoutMs: 0,
})
).toBe(5_000);
).toBe(DEFAULT_CONNECT_TIMEOUT_MS);
});
});

Expand Down Expand Up @@ -562,15 +563,55 @@ describe('httpSendStream live timeouts', () => {
expect(error).toBeInstanceOf(TimeoutError);
expect((error as TimeoutError).exitCode).toBe(9);
expect((error as TimeoutError).message).toBe(
'Request timed out: POST https://example.com/query'
'CLI timed out after 5ms reading the response: POST https://example.com/query'
);
expect((error as TimeoutError).details).toContain('--read-timeout');
}
expect(abortObserved).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});

test('connect timeout names the CLI flag and explains it is client-side', async () => {
delete process.env.ALTERTABLE_MOCK_HTTP_FILE;
const originalFetch = globalThis.fetch;

globalThis.fetch = Object.assign(
(_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
});
}),
{ preconnect: originalFetch.preconnect }
);

try {
await httpSendStream({
method: 'POST',
url: 'https://example.com/query',
authHeader: 'Authorization: Bearer test',
body: '{}',
connectTimeoutMs: 5,
readTimeoutMs: 0,
});
expect.unreachable('request should have timed out');
} catch (error) {
expect(error).toBeInstanceOf(TimeoutError);
expect((error as TimeoutError).exitCode).toBe(9);
expect((error as TimeoutError).message).toBe(
'CLI timed out after 5ms waiting for the server to respond: POST https://example.com/query'
);
const details = (error as TimeoutError).details ?? '';
expect(details).toContain('enforced by the CLI (--connect-timeout)');
expect(details).toContain('slow network');
expect(details).toContain('Raise it with --connect-timeout <seconds>.');
} finally {
globalThis.fetch = originalFetch;
}
});

test('positive readTimeoutMs also aborts stalled error response bodies', async () => {
delete process.env.ALTERTABLE_MOCK_HTTP_FILE;
const originalFetch = globalThis.fetch;
Expand Down
75 changes: 61 additions & 14 deletions cli/src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,34 @@ function unwrapErrorDetail(error: unknown): string {
return messages.join(': ');
}

function timeoutError(options: HttpSendOptions, cause: unknown): TimeoutError {
/** Which CLI-side limit an abort came from, so the error can name the flag to raise. */
type TimeoutBudget = {
flags: string;
durationMs: number;
waitingFor: string;
};

function formatDuration(durationMs: number): string {
return durationMs % 1_000 === 0
? `${durationMs / 1_000}s`
: `${durationMs}ms`;
}

function timeoutError(
options: HttpSendOptions,
budget: TimeoutBudget,
cause: unknown
): TimeoutError {
return new TimeoutError(
`Request timed out: ${options.method} ${options.url}`,
{ cause }
`CLI timed out after ${formatDuration(budget.durationMs)} ${budget.waitingFor}: ${options.method} ${options.url}`,
{
cause,
details: [
`This limit is enforced by the CLI (${budget.flags}), not reported by the server; the request may still be running server-side.`,
'A slow network or a long-running request can both trigger it.',
`Raise it with ${budget.flags.replace(' + ', ' or ')} <seconds>.`,
].join('\n'),
}
);
}

Expand All @@ -228,17 +252,19 @@ function connectionError(
function transportError(
options: HttpSendOptions,
signal: AbortSignal,
budget: () => TimeoutBudget,
cause: unknown
): NetworkError {
return signal.aborted
? timeoutError(options, cause)
? timeoutError(options, budget(), cause)
: connectionError(options, cause);
}

function wrapResponseStream(
stream: ReadableStream<Uint8Array>,
clearTimeoutAfterRead: () => void,
signal: AbortSignal,
budget: () => TimeoutBudget,
options: HttpStreamOptions
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
Expand All @@ -256,7 +282,7 @@ function wrapResponseStream(
} catch (error) {
clearTimeoutAfterRead();
reader.releaseLock();
controller.error(transportError(options, signal, error));
controller.error(transportError(options, signal, budget, error));
}
},
async cancel(reason) {
Expand Down Expand Up @@ -518,6 +544,14 @@ async function executeLiveRequest(options: HttpSendOptions): Promise<string> {
const headers = buildRequestHeaders(options);
const timeoutMs = resolveFetchTimeoutMs(options);
const signal = AbortSignal.timeout(timeoutMs);
const budget = (): TimeoutBudget => ({
flags:
resolveReadTimeoutMs(options) > 0
? '--connect-timeout + --read-timeout'
: '--connect-timeout',
durationMs: timeoutMs,
waitingFor: 'waiting for the server',
});

logHttpRequest(options);
logDebug(`Request: ${options.method} ${options.url}`);
Expand All @@ -536,14 +570,14 @@ async function executeLiveRequest(options: HttpSendOptions): Promise<string> {
buildFetchInit(options, headers, signal)
);
} catch (error) {
throw transportError(options, signal, error);
throw transportError(options, signal, budget, error);
}

let responseBody: string;
try {
responseBody = await response.text();
} catch (error) {
throw transportError(options, signal, error);
throw transportError(options, signal, budget, error);
}

logHttpDebug([
Expand Down Expand Up @@ -580,11 +614,23 @@ async function executeLiveStream(
clearTimeout(timeout);
timeout = undefined;
};
const startTimeout = (durationMs: number) => {
let activeBudget: TimeoutBudget = {
flags: '--connect-timeout',
durationMs: connectTimeoutMs,
waitingFor: 'waiting for the server to respond',
};
const budget = () => activeBudget;
const startTimeout = (next: TimeoutBudget) => {
clearActiveTimeout();
timeout = setTimeout(() => abortController.abort(), durationMs);
activeBudget = next;
timeout = setTimeout(() => abortController.abort(), next.durationMs);
};
const readBudget: TimeoutBudget = {
flags: '--read-timeout',
durationMs: readTimeoutMs,
waitingFor: 'reading the response',
};
startTimeout(connectTimeoutMs);
startTimeout(activeBudget);

logHttpRequest(options);
logDebug(`Request: ${options.method} ${options.url}`);
Expand All @@ -598,33 +644,34 @@ async function executeLiveStream(
clearActiveTimeout();
} catch (error) {
clearActiveTimeout();
throw transportError(options, abortController.signal, error);
throw transportError(options, abortController.signal, budget, error);
}

if (response.status >= 200 && response.status < 300) {
if (!response.body) {
throw new NetworkError('Response body is missing.');
}
if (readTimeoutMs > 0) {
startTimeout(readTimeoutMs);
startTimeout(readBudget);
}
return wrapResponseStream(
response.body,
clearActiveTimeout,
abortController.signal,
budget,
options
);
}

if (readTimeoutMs > 0) {
startTimeout(readTimeoutMs);
startTimeout(readBudget);
}

let responseBody: string;
try {
responseBody = await response.text();
} catch (error) {
throw transportError(options, abortController.signal, error);
throw transportError(options, abortController.signal, budget, error);
} finally {
clearActiveTimeout();
}
Expand Down
2 changes: 1 addition & 1 deletion cli/src/lib/transport-defaults.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
export const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
export const DEFAULT_READ_TIMEOUT_MS = 60_000;
export const STREAM_READ_TIMEOUT_MS = 0;
Loading