When things go wrong
Exit codes and error envelopes that tell a caller what to do next — before it reads a single byte of output.
Most automation does not fail because the happy path was hard. It fails because the unhappy path was ambiguous — a script that retried into a rate limit for an hour, or an agent that treated "wrong account" and "temporarily down" as the same thing.
The CLI's answer is that the exit code alone is enough to decide what to do next.
The exit codes
| Code | Name | What happened | What to do |
|---|---|---|---|
0 | ok | The API accepted the call | Continue |
1 | error | Unclassified failure | Read the envelope; do not retry blind |
2 | auth | Key missing, wrong, revoked, or not entitled | Stop. Retrying will not help |
3 | validation | Rejected before or by the API on the arguments | Fix the named flag and retry |
4 | confirm_required | Destructive command with no terminal and no --yes | Confirm intent, re-run with --yes |
5 | rate_limit | Rate limit or daily action cap | Stop this action for now. Not a transient error |
6 | network | Never reached the API | Retry a read; verify before retrying a write |
7 | unknown_command | No such command | Run connectsafely commands. Do not guess |
8 | conflict | Conflicts with current state | Re-read state; it may already have happened |
These are a public contract. New codes may be added; an existing one will never be repurposed.
The two that change behaviour most are 5 and 8. A bare 429 invites a retry
loop; exit 5 says the cap is the point and today is over. A bare 409 looks like a
failure; exit 8 says the invitation may already exist, so check before sending
another.
The error envelope
Errors are JSON on stderr, in every output mode — including pretty. A caller that
has to pattern-match prose to tell "rate limited" from "wrong id" will eventually get
it wrong, and the cost of that mistake lands on a real account.
{
"ok": false,
"error": "Daily connection limit reached",
"code": "rate_limited",
"exitCode": 5,
"exitName": "rate_limit",
"remediation": "A daily action cap or pacing limit was reached. Stop sending this action today and resume tomorrow; retrying now will not succeed and risks the account.",
"details": { "status": 429, "body": { … }, "retryAfterSeconds": 3600 }
}Match on code. It is stable. error is the API's own message and may be
reworded at any time.
Read remediation. It is written as an instruction, not a description. Compare
"Unauthorized" with:
The API key is missing, wrong or revoked. Check
connectsafely auth status, then regenerate at https://connectsafely.ai/api-key.
Use details to diagnose. It carries the HTTP status and the API's response
body, which is where a 400 names the field it objected to.
Codes you will meet
code | Exit | Meaning |
|---|---|---|
unknown_command, unknown_flag | 7, 3 | The name does not exist. No near-match is attempted |
missing_required_flag | 3 | A declared-required parameter is absent |
invalid_enum, above_maximum, too_long | 3 | Caught locally, before any request |
url_in_path_param, path_traversal | 3 | An identifier that would address the wrong resource |
rejected_by_api | 3 | The API rejected the arguments; details.body says which |
unauthenticated, forbidden | 2 | Bad key, or a key without the plan for this action |
rate_limited | 5 | Pacing limit or daily cap |
conflict | 8 | Already sent, already delivered, already exists |
confirmation_required | 4 | Destructive, non-interactive, no --yes |
network_error | 6 | The request never landed |
server_error | 1 | The API failed, not your request |
Which failures are retried
Retries are automatic, but not symmetric between reads and writes, because the consequences are not symmetric.
Reads are retried on 408, 425, 429, 500, 502, 503 and 504, up to
three attempts, with exponential backoff and jitter. A Retry-After header is
honoured exactly. Nothing is duplicated by reading twice.
Writes are retried in only two cases:
- The server said it did nothing —
429or503. There is no action to duplicate. --idempotency-keywas supplied — a repeated key returns the first result, so the retry is safe by construction.
A write that times out, or that fails with a 500, is not retried. The CLI
cannot know whether it landed, and sending a second connection request to the same
person is worse than reporting a failure you can check.
connectsafely --retries 5 ... # more attempts for retryable failures
connectsafely --retries 1 ... # none: fail on the first
connectsafely --timeout 15000 ... # per-attempt timeout in ms (default 60000)Idempotency keys
connectsafely conversations-send-message \
--json @message.json \
--idempotency-key "followup-lead-1041-week-3"Derive the key from the intent, not the attempt: the recipient and the campaign step, not a timestamp or a random value. A key that is stable for the intent means a re-run of the same script is a no-op rather than a second message.
Backoff behaves itself
Waits grow exponentially and are capped at 15 seconds, so a long outage does not stall a job for minutes. Every wait carries jitter, so a hundred parallel callers that all failed at once do not resynchronise and hit the API together on the way back up.
Seeing what happened
Retry chatter goes to stderr and is off by default, so stderr holds nothing but the error envelope and stays parseable:
connectsafely search-people --keywords CTO --verbose # show retry decisions
connectsafely batch invites.ndjson --quiet # silence everything but errorsAt an interactive terminal, progress is shown automatically — a human watching a slow batch should not have to guess whether it is working.
A retry loop that behaves
#!/usr/bin/env bash
set -uo pipefail
out=$(connectsafely conversations-send-message --json @msg.json \
--idempotency-key "followup-1041")
code=$?
if [ "$code" -eq 0 ]; then
echo "$out" | jq '.'
exit 0
fi
case "$code" in
2) echo "Auth problem — not retrying." >&2; exit 2 ;;
3) echo "Bad arguments — fix the payload." >&2; exit 3 ;;
5) echo "Rate limited — try again tomorrow." >&2; exit 0 ;;
6) echo "Network — safe to retry with the same key." >&2; exit 75 ;;
8) echo "Already sent." >&2; exit 0 ;;
*) echo "Unexpected failure." >&2; exit 1 ;;
esacNote what this does not do: it never retries a 2 or a 5, and it treats 8 as
success, because the message already exists.
Related
- Not sending the wrong thing — catching failures before they happen
- Running the CLI from an AI agent — how an agent uses these codes
Sending at a human rhythm
Run a list of LinkedIn actions with randomised spacing between writes — because accounts get flagged on rhythm as much as on volume.
Recipes
Complete, runnable workflows — sourcing to outreach, inbox triage, content reporting and scheduled jobs — built from CLI commands you can copy today.
