insightfactory-cli 1.0.1.dev9__tar.gz → 1.0.2.dev12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/PKG-INFO +22 -1
  2. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/README.md +21 -0
  3. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/pyproject.toml +1 -1
  4. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/api.py +32 -13
  5. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/constants.py +1 -0
  6. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/http.py +54 -4
  7. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/main.py +5 -4
  8. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/oauth.py +9 -2
  9. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_api.py +13 -0
  10. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_api_command.py +157 -0
  11. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_cli.py +4 -3
  12. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/uv.lock +1 -1
  13. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/.github/workflows/ci.yml +0 -0
  14. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/.github/workflows/claude.yml +0 -0
  15. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/.github/workflows/release.yml +0 -0
  16. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/.gitignore +0 -0
  17. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/.python-version +0 -0
  18. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/AGENTS.md +0 -0
  19. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/CLAUDE.md +0 -0
  20. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/__init__.py +0 -0
  21. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/__main__.py +0 -0
  22. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/cache.py +0 -0
  23. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/cli.py +0 -0
  24. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/colour.py +0 -0
  25. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/__init__.py +0 -0
  26. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/config.py +0 -0
  27. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/login.py +0 -0
  28. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/logout.py +0 -0
  29. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/profiles.py +0 -0
  30. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/set_token.py +0 -0
  31. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/commands/token.py +0 -0
  32. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/config.py +0 -0
  33. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/src/if_cli/runtime.py +0 -0
  34. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/__init__.py +0 -0
  35. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/cache_writer.py +0 -0
  36. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/conftest.py +0 -0
  37. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/helpers.py +0 -0
  38. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/servers.py +0 -0
  39. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_cache.py +0 -0
  40. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_config.py +0 -0
  41. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_config_command.py +0 -0
  42. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_login.py +0 -0
  43. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_oauth.py +0 -0
  44. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_oauth_flow.py +0 -0
  45. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_profiles.py +0 -0
  46. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_programmatic_api.py +0 -0
  47. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_runtime.py +0 -0
  48. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_set_token.py +0 -0
  49. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2.dev12}/tests/test_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.0.1.dev9
3
+ Version: 1.0.2.dev12
4
4
  Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
5
  Project-URL: Homepage, https://github.com/insightfactory-ai/insightfactory-cli
6
6
  Project-URL: Repository, https://github.com/insightfactory-ai/insightfactory-cli
@@ -78,6 +78,10 @@ if-cli token -p example-dev --env-name INSIGHTFACTORY_ACCESS_TOKEN_DEV
78
78
  if-cli api -p example-dev /api/agent-projects
79
79
  if-cli api -p example-dev -X POST -d '{"name":"example"}' /api/example
80
80
 
81
+ # Wait longer than the 30-second default for a long-running endpoint.
82
+ if-cli api -p example-dev --timeout 300 -X PUT -d '{"productionLineCodes":["PL001"]}' \
83
+ /api/orchestration/run
84
+
81
85
  # Discover API routes from the factory's OpenAPI document.
82
86
  if-cli api routes -p example-dev
83
87
  if-cli api routes -p example-dev agent-projects
@@ -99,6 +103,23 @@ factory origin so a profile token cannot be forwarded to another host. Profile
99
103
  hosts must be bare origins rather than URLs containing application path
100
104
  prefixes.
101
105
 
106
+ Requests time out after 30 seconds by default. Raise the deadline per
107
+ invocation with `--timeout <seconds>`, or for a whole session with
108
+ `INSIGHTFACTORY_REQUEST_TIMEOUT`; the flag wins over the environment variable,
109
+ and both are validated before the command touches the network. There is no
110
+ upper bound, because the right ceiling depends on the endpoint.
111
+
112
+ Both apply to the API request itself. The OAuth discovery and token-refresh
113
+ round trips keep the 30-second default, so `if-cli token` and in-process callers
114
+ of `if_cli.oauth.get_valid_token` always have a bounded wait that no ambient
115
+ environment value can redefine.
116
+
117
+ A timeout is a client-side deadline, not a rejection: the factory may have
118
+ accepted and completed the request after `if-cli` gave up. Re-issuing a
119
+ non-idempotent call (`POST`, `PUT`, `PATCH`, `DELETE`) can therefore apply it
120
+ twice, so the timeout message for those methods says to confirm the current
121
+ state before retrying rather than repeating the call.
122
+
102
123
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR` (useful in tests
103
124
  and CI). The default is `~/.insightfactory`.
104
125
 
@@ -62,6 +62,10 @@ if-cli token -p example-dev --env-name INSIGHTFACTORY_ACCESS_TOKEN_DEV
62
62
  if-cli api -p example-dev /api/agent-projects
63
63
  if-cli api -p example-dev -X POST -d '{"name":"example"}' /api/example
64
64
 
65
+ # Wait longer than the 30-second default for a long-running endpoint.
66
+ if-cli api -p example-dev --timeout 300 -X PUT -d '{"productionLineCodes":["PL001"]}' \
67
+ /api/orchestration/run
68
+
65
69
  # Discover API routes from the factory's OpenAPI document.
66
70
  if-cli api routes -p example-dev
67
71
  if-cli api routes -p example-dev agent-projects
@@ -83,6 +87,23 @@ factory origin so a profile token cannot be forwarded to another host. Profile
83
87
  hosts must be bare origins rather than URLs containing application path
84
88
  prefixes.
85
89
 
90
+ Requests time out after 30 seconds by default. Raise the deadline per
91
+ invocation with `--timeout <seconds>`, or for a whole session with
92
+ `INSIGHTFACTORY_REQUEST_TIMEOUT`; the flag wins over the environment variable,
93
+ and both are validated before the command touches the network. There is no
94
+ upper bound, because the right ceiling depends on the endpoint.
95
+
96
+ Both apply to the API request itself. The OAuth discovery and token-refresh
97
+ round trips keep the 30-second default, so `if-cli token` and in-process callers
98
+ of `if_cli.oauth.get_valid_token` always have a bounded wait that no ambient
99
+ environment value can redefine.
100
+
101
+ A timeout is a client-side deadline, not a rejection: the factory may have
102
+ accepted and completed the request after `if-cli` gave up. Re-issuing a
103
+ non-idempotent call (`POST`, `PUT`, `PATCH`, `DELETE`) can therefore apply it
104
+ twice, so the timeout message for those methods says to confirm the current
105
+ state before retrying rather than repeating the call.
106
+
86
107
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR` (useful in tests
87
108
  and CI). The default is `~/.insightfactory`.
88
109
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.0.1.dev9"
3
+ version = "1.0.2.dev12"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -8,7 +8,14 @@ from urllib.parse import ParseResult
8
8
 
9
9
  from if_cli.cli import Options, parse_args
10
10
  from if_cli.config import get_profile, load_config, resolve_profile_name
11
- from if_cli.http import fetch_with_timeout, parse_url, url_href, url_origin
11
+ from if_cli.http import (
12
+ fetch_with_timeout,
13
+ parse_timeout_seconds,
14
+ parse_url,
15
+ resolve_request_timeout,
16
+ url_href,
17
+ url_origin,
18
+ )
12
19
  from if_cli.oauth import get_valid_token
13
20
  from if_cli.runtime import die, is_record, set_exit_code
14
21
 
@@ -21,12 +28,13 @@ API_OPTIONS: Options = {
21
28
  "profile": {"type": "string", "short": "p"},
22
29
  "method": {"type": "string", "short": "X"},
23
30
  "data": {"type": "string", "short": "d"},
31
+ "timeout": {"type": "string"},
24
32
  }
25
33
 
26
34
  API_USAGE = (
27
- "usage: if-cli api [-p profile] [-X METHOD] [-d DATA] /api/path\n"
28
- " if-cli api routes [-p profile] [filter]\n"
29
- " if-cli api describe [-p profile] METHOD /api/path"
35
+ "usage: if-cli api [-p profile] [-X METHOD] [-d DATA] [--timeout SECONDS] /api/path\n"
36
+ " if-cli api routes [-p profile] [--timeout SECONDS] [filter]\n"
37
+ " if-cli api describe [-p profile] [--timeout SECONDS] METHOD /api/path"
30
38
  )
31
39
 
32
40
 
@@ -109,9 +117,9 @@ def resolve_api_url(host: str, api_path: str) -> ParseResult:
109
117
  return url
110
118
 
111
119
 
112
- def _fetch_openapi(host: str) -> dict[str, Any]:
120
+ def _fetch_openapi(host: str, timeout: float) -> dict[str, Any]:
113
121
  url = f"{host}/swagger/v1/swagger.json"
114
- response = fetch_with_timeout(url)
122
+ response = fetch_with_timeout(url, timeout=timeout)
115
123
  if not response.ok:
116
124
  die(f"API discovery failed for {host} (HTTP {response.status})")
117
125
  try:
@@ -125,16 +133,16 @@ def _fetch_openapi(host: str) -> dict[str, Any]:
125
133
  return body
126
134
 
127
135
 
128
- def _routes_command(positionals: list[str], profile_name: str | None) -> None:
136
+ def _routes_command(positionals: list[str], profile_name: str | None, timeout: float) -> None:
129
137
  profile = get_profile(load_config(), resolve_profile_name(profile_name))
130
138
  filter_text = positionals[1] if len(positionals) > 1 else None
131
- routes = discover_routes(_fetch_openapi(profile["host"]), filter_text)
139
+ routes = discover_routes(_fetch_openapi(profile["host"], timeout), filter_text)
132
140
  if not routes:
133
141
  die(f"no API routes matched '{filter_text or ''}'")
134
142
  sys.stdout.write(f"{chr(10).join(routes)}\n")
135
143
 
136
144
 
137
- def _describe_command(positionals: list[str], profile_name: str | None) -> None:
145
+ def _describe_command(positionals: list[str], profile_name: str | None, timeout: float) -> None:
138
146
  method = positionals[1].upper() if len(positionals) > 1 and positionals[1] else None
139
147
  path = positionals[2] if len(positionals) > 2 else None
140
148
  if not method or not path:
@@ -143,7 +151,7 @@ def _describe_command(positionals: list[str], profile_name: str | None) -> None:
143
151
  _die_on_msys_mangled_path(path)
144
152
  die(f"API path must begin with '/'; '{path}' is not a discovery document path")
145
153
  profile = get_profile(load_config(), resolve_profile_name(profile_name))
146
- operation = describe_route(_fetch_openapi(profile["host"]), method, path)
154
+ operation = describe_route(_fetch_openapi(profile["host"], timeout), method, path)
147
155
  if not operation:
148
156
  die(f"{method} {path} was not found in the API discovery document")
149
157
  sys.stdout.write(f"{json.dumps({'method': method, 'path': path, **operation}, indent=2)}\n")
@@ -151,11 +159,15 @@ def _describe_command(positionals: list[str], profile_name: str | None) -> None:
151
159
 
152
160
  def api_command(argv: list[str]) -> None:
153
161
  values, positionals = parse_args(argv, options=API_OPTIONS, allow_positionals=True)
162
+ # Resolved up front so an unusable --timeout or environment value fails before
163
+ # the command reaches for a profile, a token, or the network.
164
+ flag = parse_timeout_seconds(values["timeout"], "--timeout") if values["timeout"] is not None else None
165
+ timeout = resolve_request_timeout(flag)
154
166
  if positionals and positionals[0] == "routes":
155
- _routes_command(positionals, values["profile"])
167
+ _routes_command(positionals, values["profile"], timeout)
156
168
  return
157
169
  if positionals and positionals[0] == "describe":
158
- _describe_command(positionals, values["profile"])
170
+ _describe_command(positionals, values["profile"], timeout)
159
171
  return
160
172
 
161
173
  api_path = positionals[0] if positionals else None
@@ -178,7 +190,14 @@ def api_command(argv: list[str]) -> None:
178
190
  headers = {"Authorization": f"Bearer {token}"}
179
191
  if values["data"] is not None:
180
192
  headers["Content-Type"] = "application/json"
181
- response = fetch_with_timeout(url, method=method, headers=headers, body=values["data"])
193
+ response = fetch_with_timeout(
194
+ url,
195
+ method=method,
196
+ headers=headers,
197
+ body=values["data"],
198
+ timeout=timeout,
199
+ warn_unsafe_retry=True,
200
+ )
182
201
  sys.stderr.write(f"HTTP {response.status}\n")
183
202
  body = response.text()
184
203
  if body:
@@ -1,4 +1,5 @@
1
1
  ENV_PROFILE: str = "INSIGHTFACTORY_CONFIG_PROFILE"
2
+ ENV_REQUEST_TIMEOUT: str = "INSIGHTFACTORY_REQUEST_TIMEOUT"
2
3
  DEFAULT_SCOPE: str = "openid profile email offline_access"
3
4
  LOOPBACK_HOST: str = "127.0.0.1"
4
5
  DEFAULT_CALLBACK_PORT: int = 53682
@@ -1,15 +1,43 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import math
5
+ import os
4
6
  import socket
5
7
  import urllib.error
6
8
  import urllib.request
7
9
  from email.message import Message
10
+ from typing import NoReturn
8
11
  from urllib.parse import ParseResult, urljoin, urlparse
9
12
 
13
+ from if_cli.constants import ENV_REQUEST_TIMEOUT
10
14
  from if_cli.runtime import die
11
15
 
12
- REQUEST_TIMEOUT_MS = 30_000
16
+ DEFAULT_REQUEST_TIMEOUT_S = 30.0
17
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
18
+
19
+
20
+ def parse_timeout_seconds(value: str, label: str) -> float:
21
+ """Parse a request timeout in seconds. There is no upper bound: the right
22
+ ceiling depends on the endpoint, and a long factory call must be waitable."""
23
+ try:
24
+ timeout = float(value)
25
+ except ValueError:
26
+ die(f"{label} must be a positive number of seconds (received '{value}')")
27
+ if not math.isfinite(timeout) or timeout <= 0:
28
+ die(f"{label} must be a positive number of seconds (received '{value}')")
29
+ return timeout
30
+
31
+
32
+ def resolve_request_timeout(explicit: float | None = None) -> float:
33
+ """Per-invocation timeout, in seconds: explicit argument, then the
34
+ environment variable, then the default."""
35
+ if explicit is not None:
36
+ return explicit
37
+ from_environment = os.environ.get(ENV_REQUEST_TIMEOUT)
38
+ if from_environment is not None and from_environment.strip():
39
+ return parse_timeout_seconds(from_environment.strip(), ENV_REQUEST_TIMEOUT)
40
+ return DEFAULT_REQUEST_TIMEOUT_S
13
41
 
14
42
 
15
43
  def _with_backslashes_as_slashes(value: str) -> str:
@@ -122,13 +150,34 @@ class HttpResponse:
122
150
  return str(value)
123
151
 
124
152
 
153
+ def _die_on_timeout(url: str, method: str, timeout: float, *, warn_unsafe_retry: bool) -> NoReturn:
154
+ if not warn_unsafe_retry or method.upper() in SAFE_METHODS:
155
+ die(f"request timed out after {timeout}s: {url}")
156
+ die(
157
+ f"request timed out after {timeout}s: {url}\n"
158
+ f" The timeout is a client-side deadline, not a rejection — the factory may still have\n"
159
+ f" accepted and completed this {method.upper()}. Do not re-issue it: confirm the\n"
160
+ " current state first, and retry only if the work did not happen. Raise the\n"
161
+ " deadline with --timeout <seconds>."
162
+ )
163
+
164
+
125
165
  def fetch_with_timeout(
126
166
  url: str,
127
167
  *,
128
168
  method: str = "GET",
129
169
  headers: dict[str, str] | None = None,
130
170
  body: str | bytes | None = None,
171
+ timeout: float | None = None,
172
+ warn_unsafe_retry: bool = False,
131
173
  ) -> HttpResponse:
174
+ """Issue one request, dying on timeout.
175
+
176
+ `warn_unsafe_retry` opts into the confirm-don't-retry guidance, which only makes
177
+ sense for a request the caller issued on the user's behalf against the factory.
178
+ It is wrong advice for an OAuth round trip, where retrying is the correct response
179
+ and there is no factory-side state to confirm.
180
+ """
132
181
  data: bytes | None
133
182
  if body is None:
134
183
  data = None
@@ -137,8 +186,9 @@ def fetch_with_timeout(
137
186
  else:
138
187
  data = body.encode("utf-8")
139
188
  request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
189
+ effective_timeout = resolve_request_timeout(timeout)
140
190
  try:
141
- with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_MS / 1000) as response:
191
+ with urllib.request.urlopen(request, timeout=effective_timeout) as response:
142
192
  return HttpResponse(response.status, response.headers, response.read())
143
193
  except urllib.error.HTTPError as error:
144
194
  return HttpResponse(error.code, error.headers, error.read())
@@ -147,7 +197,7 @@ def fetch_with_timeout(
147
197
  if isinstance(reason, (TimeoutError, socket.timeout)) or getattr(reason, "errno", None) == getattr(
148
198
  socket, "ETIMEDOUT", None
149
199
  ):
150
- die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
200
+ _die_on_timeout(url, method, effective_timeout, warn_unsafe_retry=warn_unsafe_retry)
151
201
  raise
152
202
  except TimeoutError:
153
- die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
203
+ _die_on_timeout(url, method, effective_timeout, warn_unsafe_retry=warn_unsafe_retry)
@@ -10,7 +10,7 @@ from if_cli.commands.logout import logout_command
10
10
  from if_cli.commands.profiles import profiles_command
11
11
  from if_cli.commands.set_token import set_token_command
12
12
  from if_cli.commands.token import token_command
13
- from if_cli.constants import ENV_PROFILE
13
+ from if_cli.constants import ENV_PROFILE, ENV_REQUEST_TIMEOUT
14
14
  from if_cli.runtime import die
15
15
 
16
16
  USAGE = f"""usage: if-cli <command> [options]
@@ -27,11 +27,12 @@ commands:
27
27
  config print a non-secret profile value
28
28
  get <host|audience|callback_port|client_id|organization> [-p profile]
29
29
  api call or discover factory API routes
30
- [-p profile] [-X METHOD] [-d DATA] <path>
31
- routes [-p profile] [filter]
32
- describe [-p profile] METHOD <path>
30
+ [-p profile] [-X METHOD] [-d DATA] [--timeout SECONDS] <path>
31
+ routes [-p profile] [--timeout SECONDS] [filter]
32
+ describe [-p profile] [--timeout SECONDS] METHOD <path>
33
33
 
34
34
  profile selection: -p flag, then ${ENV_PROFILE}, then [DEFAULT].
35
+ request timeout: --timeout flag, then ${ENV_REQUEST_TIMEOUT}, then 30 seconds.
35
36
  """
36
37
 
37
38
 
@@ -20,7 +20,7 @@ from if_cli.cache import (
20
20
  )
21
21
  from if_cli.config import Profile, meaningful
22
22
  from if_cli.constants import DEFAULT_SCOPE, LOOPBACK_HOST
23
- from if_cli.http import fetch_with_timeout, validate_http_url
23
+ from if_cli.http import DEFAULT_REQUEST_TIMEOUT_S, fetch_with_timeout, validate_http_url
24
24
  from if_cli.runtime import CliError, die, escape_html, is_record
25
25
 
26
26
  CALLBACK_TIMEOUT_SECONDS = 300
@@ -66,7 +66,10 @@ def build_callback_url(port: int) -> str:
66
66
 
67
67
 
68
68
  def _discover(host: str) -> dict[str, Any]:
69
- response = fetch_with_timeout(f"{host}/.well-known/oauth-authorization-server")
69
+ response = fetch_with_timeout(
70
+ f"{host}/.well-known/oauth-authorization-server",
71
+ timeout=DEFAULT_REQUEST_TIMEOUT_S,
72
+ )
70
73
  if not response.ok:
71
74
  die(f"OAuth discovery failed for {host} (HTTP {response.status})")
72
75
  try:
@@ -114,11 +117,15 @@ def _parse_expires_in(value: object) -> float | None:
114
117
 
115
118
 
116
119
  def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, object]:
120
+ # Pinned to the default rather than the caller's timeout: get_valid_token is a
121
+ # documented in-process surface that promises a bounded wait, and an OAuth round
122
+ # trip has no reason to inherit a ceiling chosen for a slow factory endpoint.
117
123
  response = fetch_with_timeout(
118
124
  token_endpoint,
119
125
  method="POST",
120
126
  headers={"Content-Type": "application/x-www-form-urlencoded"},
121
127
  body=urlencode(form),
128
+ timeout=DEFAULT_REQUEST_TIMEOUT_S,
122
129
  )
123
130
  if not response.ok:
124
131
  body = response.text()
@@ -77,6 +77,19 @@ def test_api_suggests_a_placeholder_path_when_only_a_method_was_supplied():
77
77
  assert "did you mean: if-cli api -X POST /api/path ?" in result.stderr
78
78
 
79
79
 
80
+ def test_api_rejects_a_timeout_that_is_not_a_positive_number_of_seconds():
81
+ for value in ["0", "-1", "abc", "nan", "inf", ""]:
82
+ result = run_cli(["api", f"--timeout={value}", "/api/health"])
83
+ assert result.returncode == 1, value
84
+ assert "--timeout must be a positive number of seconds" in result.stderr, value
85
+
86
+
87
+ def test_api_rejects_an_invalid_request_timeout_from_the_environment():
88
+ result = run_cli(["api", "/api/health"], env={"INSIGHTFACTORY_REQUEST_TIMEOUT": "soon"})
89
+ assert result.returncode == 1
90
+ assert "INSIGHTFACTORY_REQUEST_TIMEOUT must be a positive number of seconds" in result.stderr
91
+
92
+
80
93
  def test_discovers_and_sorts_all_openapi_operations():
81
94
  assert discover_routes(OPENAPI) == [
82
95
  "DELETE /api/agent-projects/{id} Delete an agent project",
@@ -6,9 +6,16 @@ import time
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
+ import pytest
10
+
11
+ from if_cli.http import fetch_with_timeout
12
+ from if_cli.runtime import CliError
9
13
  from tests.helpers import create_config, run_cli
10
14
  from tests.servers import Request, Response, ThreadedServer
11
15
 
16
+ SLOW_RESPONSE_S = 0.6
17
+ SHORT_TIMEOUT_S = "0.1"
18
+
12
19
 
13
20
  def _api_server(mode: str = "valid") -> tuple[ThreadedServer, dict[str, Any]]:
14
21
  state: dict[str, Any] = {"return_null_swagger": False}
@@ -35,6 +42,9 @@ def _api_server(mode: str = "valid") -> tuple[ThreadedServer, dict[str, Any]]:
35
42
  "contentType": request.headers.get("Content-Type") or request.headers.get("content-type"),
36
43
  }
37
44
  )
45
+ if request.path == "/slow":
46
+ time.sleep(SLOW_RESPONSE_S)
47
+ return Response(body={"accepted": True})
38
48
  if request.path == "/invalid-json":
39
49
  return Response(body="not-json", headers={"Content-Type": "application/json"})
40
50
  if request.path == "/error":
@@ -322,3 +332,150 @@ def test_api_flushes_a_complete_error_body_before_exiting_unsuccessfully():
322
332
  finally:
323
333
  server.close()
324
334
  shutil.rmtree(config_dir, ignore_errors=True)
335
+
336
+
337
+ def test_api_timeout_flag_raises_the_deadline_over_the_environment_default():
338
+ server, _state = _api_server("valid")
339
+ config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
340
+ _write_token(config_dir, server.origin)
341
+ environment = {
342
+ "INSIGHTFACTORY_CONFIG_DIR": config_dir,
343
+ "INSIGHTFACTORY_REQUEST_TIMEOUT": SHORT_TIMEOUT_S,
344
+ }
345
+ try:
346
+ without_flag = run_cli(["api", "/slow"], env=environment)
347
+ assert without_flag.returncode == 1
348
+ assert f"request timed out after {float(SHORT_TIMEOUT_S)}s" in without_flag.stderr
349
+
350
+ with_flag = run_cli(["api", "--timeout", "30", "/slow"], env=environment)
351
+ assert with_flag.returncode == 0, with_flag.stderr
352
+ assert json.loads(with_flag.stdout) == {"accepted": True}
353
+ finally:
354
+ server.close()
355
+ shutil.rmtree(config_dir, ignore_errors=True)
356
+
357
+
358
+ def test_api_timeout_on_a_non_idempotent_request_says_it_may_still_have_landed():
359
+ server, _state = _api_server("valid")
360
+ config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
361
+ _write_token(config_dir, server.origin)
362
+ try:
363
+ result = run_cli(
364
+ ["api", "--timeout", SHORT_TIMEOUT_S, "-X", "PUT", "-d", '{"productionLineCodes":["PL001"]}', "/slow"],
365
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
366
+ )
367
+ assert result.returncode == 1
368
+ assert f"request timed out after {float(SHORT_TIMEOUT_S)}s" in result.stderr
369
+ assert "the factory may still have" in result.stderr
370
+ assert "accepted and completed this PUT" in result.stderr
371
+ assert "Do not re-issue it" in result.stderr
372
+ finally:
373
+ server.close()
374
+ shutil.rmtree(config_dir, ignore_errors=True)
375
+
376
+
377
+ def test_api_timeout_on_a_read_stays_a_plain_timeout():
378
+ server, _state = _api_server("valid")
379
+ config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
380
+ _write_token(config_dir, server.origin)
381
+ try:
382
+ result = run_cli(
383
+ ["api", "--timeout", SHORT_TIMEOUT_S, "/slow"],
384
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
385
+ )
386
+ assert result.returncode == 1
387
+ assert f"request timed out after {float(SHORT_TIMEOUT_S)}s" in result.stderr
388
+ assert "Do not re-issue it" not in result.stderr
389
+ finally:
390
+ server.close()
391
+ shutil.rmtree(config_dir, ignore_errors=True)
392
+
393
+
394
+ def test_api_routes_honours_the_timeout_flag_during_discovery():
395
+ server, _state = _api_server("valid")
396
+ config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
397
+ try:
398
+ result = run_cli(
399
+ ["api", "routes", "--timeout", "60"],
400
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
401
+ )
402
+ assert result.returncode == 0, result.stderr
403
+ assert "GET /api/health" in result.stdout
404
+ finally:
405
+ server.close()
406
+ shutil.rmtree(config_dir, ignore_errors=True)
407
+
408
+
409
+ def test_token_refresh_ignores_a_short_ambient_request_timeout():
410
+ """`get_valid_token` promises a bounded wait, so the auth leg keeps the default.
411
+
412
+ Before the timeout override existed there was nothing to inherit; now an ambient
413
+ INSIGHTFACTORY_REQUEST_TIMEOUT must not shorten a token refresh out from under an
414
+ in-process caller.
415
+ """
416
+
417
+ def handler(request: Request) -> Response:
418
+ if request.path == "/token":
419
+ time.sleep(SLOW_RESPONSE_S)
420
+ return Response(body={"access_token": "refreshed", "expires_in": 3600})
421
+ return Response(body={"ok": True})
422
+
423
+ server = ThreadedServer(handler).start()
424
+ config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\nclient_id = test-client\n")
425
+ Path(config_dir, "token-cache.json").write_text(
426
+ json.dumps(
427
+ {
428
+ "version": 1,
429
+ "tokens": {
430
+ server.origin: {
431
+ "access_token": "stale",
432
+ "refresh_token": "test-refresh",
433
+ "expires_at": int(time.time()) - 10,
434
+ "token_endpoint": f"{server.origin}/token",
435
+ "client_id": "test-client",
436
+ }
437
+ },
438
+ }
439
+ ),
440
+ encoding="utf-8",
441
+ )
442
+ try:
443
+ result = run_cli(
444
+ ["api", "--timeout", "30", "/api/health"],
445
+ env={
446
+ "INSIGHTFACTORY_CONFIG_DIR": config_dir,
447
+ "INSIGHTFACTORY_REQUEST_TIMEOUT": SHORT_TIMEOUT_S,
448
+ },
449
+ )
450
+ assert result.returncode == 0, result.stderr
451
+ assert "could not refresh token" not in result.stderr
452
+ assert json.loads(result.stdout) == {"ok": True}
453
+ finally:
454
+ server.close()
455
+ shutil.rmtree(config_dir, ignore_errors=True)
456
+
457
+
458
+ def test_only_the_opted_in_caller_gets_the_confirm_dont_retry_guidance():
459
+ """A timed-out OAuth POST must not be told to confirm factory state first."""
460
+
461
+ def handler(request: Request) -> Response:
462
+ time.sleep(SLOW_RESPONSE_S)
463
+ return Response(body={"ok": True})
464
+
465
+ server = ThreadedServer(handler).start()
466
+ try:
467
+ with pytest.raises(CliError) as unopted:
468
+ fetch_with_timeout(f"{server.origin}/token", method="POST", timeout=float(SHORT_TIMEOUT_S))
469
+ assert "request timed out after" in str(unopted.value)
470
+ assert "Do not re-issue it" not in str(unopted.value)
471
+
472
+ with pytest.raises(CliError) as opted:
473
+ fetch_with_timeout(
474
+ f"{server.origin}/api/orchestration/run",
475
+ method="PUT",
476
+ timeout=float(SHORT_TIMEOUT_S),
477
+ warn_unsafe_retry=True,
478
+ )
479
+ assert "Do not re-issue it" in str(opted.value)
480
+ finally:
481
+ server.close()
@@ -212,7 +212,7 @@ def test_parse_args_accepts_positionals_after_options():
212
212
  options=API_OPTIONS,
213
213
  allow_positionals=True,
214
214
  )
215
- assert values_after == values_before == {"profile": "foundry-dev", "method": None, "data": None}
215
+ assert values_after == values_before == {"profile": "foundry-dev", "method": None, "data": None, "timeout": None}
216
216
  assert positionals_after == positionals_before == ["routes", "task"]
217
217
 
218
218
  describe_after, pos_after = parse_args(
@@ -225,7 +225,8 @@ def test_parse_args_accepts_positionals_after_options():
225
225
  options=API_OPTIONS,
226
226
  allow_positionals=True,
227
227
  )
228
- assert describe_after == describe_before == {"profile": "foundry-dev", "method": None, "data": None}
228
+ expected = {"profile": "foundry-dev", "method": None, "data": None, "timeout": None}
229
+ assert describe_after == describe_before == expected
229
230
  assert pos_after == pos_before == ["describe", "GET", "/api/tasks"]
230
231
 
231
232
  config_after, config_pos_after = parse_args(
@@ -248,7 +249,7 @@ def test_parse_args_treats_tokens_after_double_dash_as_positionals():
248
249
  options=API_OPTIONS,
249
250
  allow_positionals=True,
250
251
  )
251
- assert values == {"profile": "foundry-dev", "method": None, "data": None}
252
+ assert values == {"profile": "foundry-dev", "method": None, "data": None, "timeout": None}
252
253
  assert positionals == ["routes", "-X", "GET"]
253
254
 
254
255
 
@@ -34,7 +34,7 @@ wheels = [
34
34
 
35
35
  [[package]]
36
36
  name = "insightfactory-cli"
37
- version = "1.0.1"
37
+ version = "1.0.2"
38
38
  source = { editable = "." }
39
39
 
40
40
  [package.dev-dependencies]