Skip to content

Commit cdd3bf6

Browse files
authored
Configurable CLI exit behavior for API errors (#211)
* feat: add --exit-code-on-api-error flag + Buildkite-aware infra error logging Adds a configurable exit code for API/infrastructure failures so CI pipelines can distinguish them from blocking security findings (exit 1), without changing any default behavior. - New CliConfig field exit_code_on_api_error (default 3) + --exit-code-on-api-error flag. The CLI already exited 3 on unexpected errors; this just makes that code configurable (e.g. remap to a Buildkite soft_fail code, or 0 to swallow). - New _emit_infrastructure_error helper + IS_BUILDKITE gate: emits Buildkite log section markers (^^^ +++ / --- ⚠️) and a soft_fail hint when running in Buildkite; plain log.error elsewhere so markers don't leak as literal text. - Wire the top-level generic-exception handler in cli() through the helper and the configurable code. Deliberately NON-breaking for 2.3.x: - --disable-blocking STILL forces exit 0 for all outcomes and takes precedence over --exit-code-on-api-error (documented in the flag help so the two aren't combined by mistake). - Default exit codes are unchanged; the exit code only changes when the user explicitly passes the flag. The breaking variant (infra errors bypassing --disable-blocking, distinct RequestTimeoutExceeded handling, exit 1 -> 3 for diff API failures) is intentionally deferred to a future 3.0 release. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * feat(config): auto-truncate commit messages over 200 chars The --commit-message flag passes its value directly into the API request URL as a query parameter with no length limit. AI-generated commit messages and the common CI pattern of concatenating $BUILDKITE_BUILD_NUMBER + $BUILDKITE_MESSAGE can easily exceed URL length limits, producing HTTP 413 errors. The 413 originates from an infrastructure-layer URL length limit (nginx/Cloudflare), not application-level validation -- confirmed via inspection of the Socket API route handler, which has no constraint on commit_message (unlike committers, which enforces <= 200 chars and returns a clean 400). 200 chars chosen as a conservative defensive ceiling given URL encoding can 2-3x raw character count. No customer should ever want a 2000-character commit message in their scan metadata. A backend-side validation (returning 400 instead of 413) is filed as a follow-on for the depscan API team. Motivated by customer incidents (Plaid). Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * pass timeout through SDK diff requests Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: propagate --exclude-license-details to the full-scan diff request The full-scan diff comparison ignored --exclude-license-details: the flag was applied to full-scan params and report URLs but never forwarded to the fullscans.stream_diff request, so diff comparisons always fetched license details regardless of the flag. Thread it through get_added_and_removed_packages -> stream_diff via a new include_license_details param (defaulting True to preserve current behavior). Non-breaking: the APIFailure handling at this call site is deliberately left as-is (exit 1, --disable-blocking -> 0). Re-routing diff APIFailures through the top-level exit-3 path is part of the 3.0 exit-code change, not this one. Originally from the unreleased PR #195 branch; the timeout-propagation half already landed in the preceding commit. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * test: cover --exit-code-on-api-error, truncation, and Buildkite formatting tests/unit/test_cli_config.py - exit_code_on_api_error default 3 / custom / zero - commit-message truncation: passthrough under 200, truncate over 200, quote-strip-before-truncate tests/unit/test_socketcli.py - unexpected error exits 3 by default - --exit-code-on-api-error 100 remaps the failure exit code - --disable-blocking OVERRIDES --exit-code-on-api-error (-> 0): locks in the documented precedence so the soft_fail guidance can't silently regress - KeyboardInterrupt still exits 2 - _emit_infrastructure_error: BK markers + soft_fail hint only when IS_BUILDKITE; traceback gated on include_traceback Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * chore(release): 2.3.0 -- configurable API-error exit code Minor bump for the new --exit-code-on-api-error flag and the supporting non-breaking improvements (commit-message truncation, Buildkite-aware infra error logging, --timeout / --exclude-license-details fixes). This release is intentionally NON-breaking: default exit codes are unchanged, the exit code only shifts when --exit-code-on-api-error is explicitly passed, and --disable-blocking keeps its existing precedence. The breaking exit-code behavior change (infra errors exiting non-zero even under --disable-blocking) is deferred to a future 3.0. CHANGELOG + README document the flag AND its interaction with --disable-blocking (which overrides it) to reduce user error in the Buildkite soft_fail setup. Version refs synced across pyproject.toml, socketsecurity/__init__.py, and uv.lock (per the version-incrementation CI check). Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com>
1 parent 6969361 commit cdd3bf6

11 files changed

Lines changed: 326 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,51 @@
11
# Changelog
22

3+
## 2.3.0
4+
5+
### New: `--exit-code-on-api-error`
6+
7+
Adds a configurable exit code for API / infrastructure failures (timeouts,
8+
network errors, unexpected exceptions), so CI pipelines can distinguish them
9+
from blocking security findings (exit `1`):
10+
11+
```
12+
socketcli --exit-code-on-api-error 100 ...
13+
```
14+
15+
Default is `3` (the code the CLI already used for these errors), so **default
16+
behavior is unchanged** — the exit code only changes when you pass the flag.
17+
Set it to a Buildkite `soft_fail` code, or to `0` to swallow infra errors.
18+
19+
**Interaction to be aware of:** `--disable-blocking` forces exit `0` for *all*
20+
outcomes and therefore overrides `--exit-code-on-api-error`. Use the new flag
21+
*without* `--disable-blocking` if you want a custom infra-error code to take
22+
effect. See the exit-code reference in the README.
23+
24+
> A future `3.0` release is planned to make infrastructure errors exit non-zero
25+
> even under `--disable-blocking` (so outages stop being silently swallowed).
26+
> That is a breaking change and is intentionally **not** in this release.
27+
28+
### New: commit message auto-truncation
29+
30+
`--commit-message` values longer than 200 characters are now automatically
31+
truncated before being sent to the API, preventing HTTP 413 errors from
32+
oversized URL query parameters (common with AI-generated commit messages or
33+
`$BUILDKITE_MESSAGE`).
34+
35+
### Improved: Buildkite log formatting
36+
37+
When running inside a Buildkite job (`BUILDKITE=true`), infrastructure errors
38+
emit Buildkite log section markers (`^^^ +++` / `--- :warning:`) so the error
39+
section auto-expands in the BK UI, plus a `soft_fail` hint. No effect on other
40+
CI platforms.
41+
42+
### Fixed
43+
44+
- `--timeout` is now honored end-to-end: it was only applied to the local
45+
`CliClient`, but the full-scan diff comparison uses the Socket SDK instance,
46+
which was constructed without the CLI timeout and defaulted to 1200s.
47+
- `--exclude-license-details` now propagates to the full-scan diff comparison
48+
request (it was only applied to full-scan params / report URLs before).
349
## 2.2.93
450

551
- Bundled twelve Dependabot dependency updates: `urllib3`, `gitpython`, `python-dotenv`, `pytest`, `uv`, `cryptography`, `pygments`, `requests`, and `idna` (main app), plus `axios`, `requests`, and `flask` (e2e fixtures). `idna` 3.11 → 3.15 includes the fix for CVE-2026-45409.

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,48 @@ Minimal pattern:
194194
SOCKET_SECURITY_API_TOKEN: ${{ secrets.SOCKET_SECURITY_API_TOKEN }}
195195
```
196196
197+
## Exit codes
198+
199+
| Code | Meaning |
200+
|------|---------|
201+
| `0` | Clean scan — no blocking issues (or `--disable-blocking` set) |
202+
| `1` | Blocking security finding(s) detected |
203+
| `2` | Scan interrupted (SIGINT / Ctrl+C) |
204+
| `3` | Infrastructure or API error (timeout, network failure, unexpected error) |
205+
206+
`--exit-code-on-api-error <N>` remaps the infrastructure-error code (`3`) to any
207+
value — e.g. a Buildkite `soft_fail` code, or `0` to swallow infra errors. Exit
208+
`3` is a Socket convention, not an industry standard.
209+
210+
### How these options interact
211+
212+
The two flags that affect exit codes can cancel each other out, so the order of
213+
precedence matters:
214+
215+
- **`--disable-blocking` wins over everything.** It forces exit `0` for *all*
216+
outcomes — security findings *and* infrastructure errors. If you set it,
217+
`--exit-code-on-api-error` has no effect (you'll always get `0`).
218+
- **`--exit-code-on-api-error` only applies when `--disable-blocking` is *not*
219+
set.** It changes the infra-error code (and the generic-error code); it never
220+
touches the security-finding code (`1`).
221+
222+
So for the common "don't let Socket outages block my pipeline, but still fail on
223+
real findings" goal, use `--exit-code-on-api-error` **without** `--disable-blocking`:
224+
225+
```yaml
226+
# Buildkite: soft-fail only on infrastructure errors, still block on findings
227+
steps:
228+
- label: ":lock: Socket Security Scan"
229+
command: "socketcli --exit-code-on-api-error 100 ..." # NOT --disable-blocking
230+
soft_fail:
231+
- exit_status: 100
232+
```
233+
234+
Combining `--disable-blocking` with `--exit-code-on-api-error 100` would make the
235+
scan exit `0` on *both* findings and outages — the `soft_fail: 100` rule would
236+
never match, and real findings would stop blocking. That's usually not what you
237+
want.
238+
197239
## Common gotchas
198240

199241
See [`docs/troubleshooting.md`](https://github.com/SocketDev/socket-python-cli/blob/main/docs/troubleshooting.md#common-gotchas).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.2.93"
9+
version = "2.3.0"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.2.93'
2+
__version__ = '2.3.0'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/config.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class CliConfig:
101101
pending_head: bool = False
102102
enable_diff: bool = False
103103
timeout: Optional[int] = 1200
104+
exit_code_on_api_error: int = 3
104105
exclude_license_details: bool = False
105106
include_module_folders: bool = False
106107
repo_is_public: bool = False
@@ -182,6 +183,19 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
182183
if commit_message and commit_message.startswith('"') and commit_message.endswith('"'):
183184
commit_message = commit_message[1:-1]
184185

186+
# Truncate to avoid 413s from oversized URL query parameters.
187+
# The API has no application-layer length validation on commit_message;
188+
# the 413 originates from an infrastructure-layer URL length limit
189+
# (nginx/Cloudflare). 200 chars chosen as a conservative ceiling given
190+
# URL encoding can 2-3x raw character count.
191+
MAX_COMMIT_MESSAGE_LENGTH = 200
192+
if commit_message and len(commit_message) > MAX_COMMIT_MESSAGE_LENGTH:
193+
logging.debug(
194+
f"commit_message truncated from {len(commit_message)} to "
195+
f"{MAX_COMMIT_MESSAGE_LENGTH} characters to avoid API request size limits"
196+
)
197+
commit_message = commit_message[:MAX_COMMIT_MESSAGE_LENGTH]
198+
185199
config_args = {
186200
'api_token': api_token,
187201
'repo': args.repo,
@@ -219,6 +233,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
219233
'integration_type': args.integration,
220234
'pending_head': args.pending_head,
221235
'timeout': args.timeout,
236+
'exit_code_on_api_error': args.exit_code_on_api_error,
222237
'exclude_license_details': args.exclude_license_details,
223238
'include_module_folders': args.include_module_folders,
224239
'repo_is_public': args.repo_is_public,
@@ -802,6 +817,21 @@ def create_argument_parser() -> argparse.ArgumentParser:
802817
help="Timeout in seconds for API requests",
803818
required=False
804819
)
820+
advanced_group.add_argument(
821+
"--exit-code-on-api-error",
822+
dest="exit_code_on_api_error",
823+
type=int,
824+
default=3,
825+
metavar="<int>",
826+
help=(
827+
"Exit code to use when the CLI fails on an API or infrastructure error "
828+
"(timeout, network failure, unexpected exception). Default: 3. Useful for "
829+
"distinguishing infrastructure failures from security findings (exit 1) in "
830+
"CI -- e.g. set to a Buildkite soft_fail code. NOTE: --disable-blocking "
831+
"forces exit 0 for ALL outcomes and therefore overrides this flag; do not "
832+
"combine the two if you want the custom code to take effect."
833+
)
834+
)
805835
advanced_group.add_argument(
806836
"--allow-unverified",
807837
action="store_true",

socketsecurity/core/__init__.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,8 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in
941941
def get_added_and_removed_packages(
942942
self,
943943
head_full_scan_id: str,
944-
new_full_scan_id: str
944+
new_full_scan_id: str,
945+
include_license_details: bool = True
945946
) -> Tuple[Dict[str, Package], Dict[str, Package], Dict[str, Package]]:
946947
"""
947948
Get packages that were added and removed between scans.
@@ -958,12 +959,12 @@ def get_added_and_removed_packages(
958959
diff_start = time.time()
959960
try:
960961
diff_report = (
961-
self.sdk.fullscans.stream_diff
962-
(
962+
self.sdk.fullscans.stream_diff(
963963
self.config.org_slug,
964964
head_full_scan_id,
965965
new_full_scan_id,
966-
use_types=True
966+
use_types=True,
967+
include_license_details=str(include_license_details).lower()
967968
).data
968969
)
969970
except APIFailure as e:
@@ -1175,7 +1176,11 @@ def create_new_diff(
11751176
added_packages,
11761177
removed_packages,
11771178
packages
1178-
) = self.get_added_and_removed_packages(head_full_scan_id, new_full_scan.id)
1179+
) = self.get_added_and_removed_packages(
1180+
head_full_scan_id,
1181+
new_full_scan.id,
1182+
include_license_details=getattr(params, "include_license_details", True)
1183+
)
11791184

11801185
# Separate unchanged packages from added/removed for --strict-blocking support
11811186
unchanged_packages = {

socketsecurity/socketcli.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,37 @@
2727

2828
load_dotenv()
2929

30+
# Buildkite sets BUILDKITE=true in every job environment. Used to gate log
31+
# section markers that would render as literal text on other CI platforms.
32+
IS_BUILDKITE = os.getenv("BUILDKITE") == "true"
33+
34+
35+
def _emit_infrastructure_error(message: str, include_traceback: bool = False) -> None:
36+
"""Emit a structured error for infrastructure/API failures.
37+
38+
When running in Buildkite, wraps the error in log-section markers
39+
(`^^^ +++` expands the section in the BK UI) and prints a soft_fail hint.
40+
On every other platform it's a plain log.error so the markers don't leak
41+
as literal text. This is presentation only -- it does not decide the exit
42+
code (the caller does that, honoring --disable-blocking and
43+
--exit-code-on-api-error).
44+
"""
45+
if IS_BUILDKITE:
46+
print("^^^ +++", flush=True)
47+
print("--- :warning: Socket infrastructure error", flush=True)
48+
49+
log.error(message)
50+
51+
if IS_BUILDKITE:
52+
log.error(
53+
"Tip: this is an infrastructure error, not a security finding. To keep it "
54+
"from blocking the build, add a soft_fail rule for the CLI's API-error exit "
55+
"code (default 3, or whatever you pass to --exit-code-on-api-error)."
56+
)
57+
58+
if include_traceback:
59+
traceback.print_exc()
60+
3061

3162
def build_license_artifact_payload(
3263
diff: Diff,
@@ -62,6 +93,23 @@ def _write_attribution_file(config, payload: dict) -> None:
6293
Core.save_file(config.license_file_name, json.dumps(payload, indent=2))
6394

6495

96+
DEFAULT_API_TIMEOUT = 1200
97+
98+
99+
def get_api_request_timeout(config: CliConfig) -> int:
100+
return config.timeout if config.timeout is not None else DEFAULT_API_TIMEOUT
101+
102+
103+
def build_socket_sdk(config: CliConfig) -> socketdev:
104+
cli_user_agent_string = f"SocketPythonCLI/{config.version}"
105+
return socketdev(
106+
token=config.api_token,
107+
timeout=get_api_request_timeout(config),
108+
allow_unverified=config.allow_unverified,
109+
user_agent=cli_user_agent_string
110+
)
111+
112+
65113
def cli():
66114
try:
67115
main_code()
@@ -73,12 +121,17 @@ def cli():
73121
else:
74122
sys.exit(0)
75123
except Exception as error:
76-
log.error("Unexpected error when running the cli")
77-
log.error(error)
78-
traceback.print_exc()
79124
config = CliConfig.from_args() # Get current config
125+
_emit_infrastructure_error(
126+
f"Unexpected error when running the CLI: {error}",
127+
include_traceback=True,
128+
)
129+
# --disable-blocking forces a clean exit for ALL outcomes (it takes
130+
# precedence over --exit-code-on-api-error); otherwise infra/API errors
131+
# exit with the configurable code (default 3), keeping them distinct
132+
# from blocking security findings (exit 1).
80133
if not config.disable_blocking:
81-
sys.exit(3)
134+
sys.exit(config.exit_code_on_api_error)
82135
else:
83136
sys.exit(0)
84137

@@ -99,8 +152,7 @@ def main_code():
99152
"1. Command line: --api-token YOUR_TOKEN\n"
100153
"2. Environment variable: SOCKET_SECURITY_API_TOKEN")
101154
sys.exit(3)
102-
cli_user_agent_string = f"SocketPythonCLI/{config.version}"
103-
sdk = socketdev(token=config.api_token, allow_unverified=config.allow_unverified, user_agent=cli_user_agent_string)
155+
sdk = build_socket_sdk(config)
104156

105157
# Suppress urllib3 InsecureRequestWarning when using --allow-unverified
106158
if config.allow_unverified:
@@ -119,7 +171,7 @@ def main_code():
119171
socket_config = SocketConfig(
120172
api_key=config.api_token,
121173
allow_unverified_ssl=config.allow_unverified,
122-
timeout=config.timeout if config.timeout is not None else 1200 # Use CLI timeout if provided
174+
timeout=get_api_request_timeout(config)
123175
)
124176
log.debug("loaded socket_config")
125177
client = CliClient(socket_config)

tests/core/test_sdk_methods.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def test_get_added_and_removed_packages(core):
101101
"head",
102102
"new",
103103
use_types=True,
104+
include_license_details="true",
104105
)
105106

106107
# Verify the results

tests/unit/test_cli_config.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
11
import pytest
22
from socketsecurity.config import CliConfig
33

4+
5+
class TestExitCodeOnApiError:
6+
def test_default_is_3(self):
7+
config = CliConfig.from_args(["--api-token", "test"])
8+
assert config.exit_code_on_api_error == 3
9+
10+
def test_custom_value(self):
11+
config = CliConfig.from_args(
12+
["--api-token", "test", "--exit-code-on-api-error", "100"]
13+
)
14+
assert config.exit_code_on_api_error == 100
15+
16+
def test_zero_value(self):
17+
config = CliConfig.from_args(
18+
["--api-token", "test", "--exit-code-on-api-error", "0"]
19+
)
20+
assert config.exit_code_on_api_error == 0
21+
22+
23+
class TestCommitMessageTruncation:
24+
def test_passes_through_under_limit(self):
25+
msg = "a normal short commit message"
26+
config = CliConfig.from_args(["--api-token", "test", "--commit-message", msg])
27+
assert config.commit_message == msg
28+
29+
def test_truncated_above_limit(self):
30+
config = CliConfig.from_args(
31+
["--api-token", "test", "--commit-message", "a" * 250]
32+
)
33+
assert config.commit_message == "a" * 200
34+
35+
def test_quote_strip_runs_before_truncation(self):
36+
quoted = '"' + ("b" * 250) + '"'
37+
config = CliConfig.from_args(
38+
["--api-token", "test", "--commit-message", quoted]
39+
)
40+
assert config.commit_message == "b" * 200
41+
42+
443
class TestCliConfig:
544
def test_api_token_from_env(self, monkeypatch):
645
monkeypatch.setenv("SOCKET_SECURITY_API_KEY", "test-token")

0 commit comments

Comments
 (0)