curl exits 3 on a Jenkins API call: square brackets in tree= are not URL-safe
Symptom
I was automating a build trigger against a Jenkins controller from a shell script: start a parameterised job, then follow the queue item until it turns into a real build. The queries worked perfectly when I pasted them into a browser with my session cookie. From the shell, the same URL produced nothing at all:
$ curl -u ci-user:$JENKINS_TOKEN "$JENKINS/queue/item/42/api/json?tree=executable[number]"
curl: (3) bad range in URL position 45:
https://ci.example.com/queue/item/42/api/json?tree=executable[number]
^
$ echo $?
3
Every nested tree= expression behaved the same way. Listing a job’s parameter definitions:
$ curl -u ci-user:$JENKINS_TOKEN "$JENKINS/job/nightly/api/json?tree=actions[parameterDefinitions[name,type]]"
curl: (3) bad range in URL position 42:
https://ci.example.com/job/nightly/api/json?tree=actions[parameterDefinitions[name,type]]
^
Note the caret: it points at the first [, and there is no HTTP status line, no headers, no body. The
request never happened.
What it is not
The instinct in a CI context is to suspect the server or the path to it, and that is where the time goes if you do not read the exit code first.
It is not authentication. An expired or mistyped API token gets you a 401 with a response body and
curl exit code 0 — curl considers “I delivered your request and got an answer” a success, and you
need -f/--fail or -w '%{http_code}' to turn HTTP errors into shell errors. Here there is no status
code to fail on.
It is not a Jenkins 404 from a wrong job name or a missing queue item. Those also return a response: HTML
or a JSON error, plus exit code 0.
It is not a proxy, a TLS problem or a firewall. Those live in curl’s exit codes 5, 6, 7, 35, 60 —
resolution, connection, handshake. Exit 3 is curl’s “the URL you handed me is malformed” class, raised by
the client before a socket is opened. Nothing left the machine.
The two-second confirmation is to strip the brackets and keep everything else identical:
curl -u ci-user:$JENKINS_TOKEN "$JENKINS/job/nightly/12/api/json?tree=number,result"
Same host, same credentials, same endpoint — and it returns JSON. The URL, not the server, is what
curl objected to. Anything you were about to check on the Jenkins side is a detour.
What I measured
Two observations pinned it down.
First, the caret in the diagnostic always lands on [, never on the path, the ? or the parameter name.
curl has its own URL globbing syntax: [1-10] expands to a numeric range and {a,b} to a set, so that
one command line can fetch many URLs. That parser runs first, sees [number] and [parameterDefinitions...],
tries to read them as ranges, fails, and reports “bad range”. The browser has no such layer, which is
exactly why pasting the URL into a tab worked and hid the problem.
Second, disabling globbing changes the failure mode completely:
$ curl --globoff -u ci-user:$JENKINS_TOKEN "$JENKINS/queue/item/42/api/json?tree=executable[number]"
With --globoff the request goes out — the next thing you see is a normal response or a normal transport
error, not exit 3. That is the proof that the glob parser was the gatekeeper, not the network and not
Jenkins.
So there are two layers of the same trouble. The one you hit is curl’s glob syntax. The one underneath is
that [ and ] are reserved characters in RFC 3986 — they are set aside for IPv6 literals in the host
component and are not valid unescaped elsewhere in a URI. Jenkins’ tree= filter is built entirely out of
brackets, so every non-trivial tree expression is a hand-assembled URL containing reserved characters.
Browsers and many HTTP clients paper over that by encoding them silently; curl does not.
The fix
Stop concatenating the query string yourself and let curl build it. --data-urlencode percent-encodes a
value, and -G moves the accumulated data into the query string of a GET instead of a request body:
JENKINS=https://ci.example.com
# build number of a queue item, once it has been scheduled
curl -sf -u ci-user:$JENKINS_TOKEN \
-G --data-urlencode 'tree=executable[number]' \
"$JENKINS/queue/item/42/api/json"
# the parameters a job accepts, with their types
curl -sf -u ci-user:$JENKINS_TOKEN \
-G --data-urlencode 'tree=actions[parameterDefinitions[name,type]]' \
"$JENKINS/job/nightly/api/json"
What reaches the server is the encoded form — in a request log the second one shows up as
GET /job/nightly/api/json?tree=actions%5bparameterDefinitions%5bname%2ctype%5d%5d, which is what Jenkins
wants and what the spec allows.
--globoff works too, and is the right tool when you cannot restructure the call — a URL coming from a
variable, a one-liner you are pasting from a colleague. But it only silences curl’s own glob parser; the
raw brackets still travel in the URL, and the moment a value contains a space, an ampersand or a comma that
means something to the shell or the server, you are back to hand-encoding. -G --data-urlencode handles
all of those cases with the same syntax, so it is the better habit: one flag pair, no thinking about which
characters are dangerous today.
While you are there: triggering a build and finding its number
Two neighbours of this problem come up in the same session, because you meet them in the same script.
If you authenticate with a Jenkins API token, you do not need a CSRF crumb. That is worth knowing,
because most of the snippets on the internet start with an extra request to crumbIssuer and it is pure
noise for token auth:
curl -si -u ci-user:$JENKINS_TOKEN -X POST \
--data-urlencode 'BRANCH=main' \
--data-urlencode 'DEPLOY=false' \
"$JENKINS/job/nightly/buildWithParameters"
The response is 201 Created with a Location: header pointing at .../queue/item/<N>/. That is a
queue item, not a build: it has no build number yet, because the job has not been assigned an executor.
Poll that URL and read executable[number] — the very expression that started this post — and once the
item leaves the queue you get the build number, which is the handle you need for .../<number>/api/json
or the console log.
The second neighbour: a pipeline job can load its Jenkinsfile from SCM. In the job’s configuration that
is a CpsScmFlowDefinition with a scriptPath, meaning the job’s behaviour changes with a commit, with
no Jenkins configuration change and no entry in the job’s config history. You can check this as an ordinary
service account with build permission, without “Manage Jenkins” rights:
curl -sf -u ci-user:$JENKINS_TOKEN "$JENKINS/job/nightly/config.xml"
If the <definition> element is CpsScmFlowDefinition, stop looking at Jenkins and go read the repository
history for scriptPath — that is where the change is.
What to remember
Read exit codes before HTTP status codes. A non-zero exit with no response body means the request was
never made, and everything you know about the server is irrelevant until the client is happy. Conversely,
curl exiting 0 says nothing about whether the server liked your request; add -f if you want failures
to be failures.
When an API’s own query language is made of reserved characters — brackets here, but +, & and spaces
cause the same class of grief — treat every manually assembled URL as a bug that has not fired yet. Push
the encoding down into the tool that knows the rules.
And when something works in the browser but not in the shell, the difference is rarely authentication. It
is usually a layer the browser adds for you: URL encoding, redirect following, a cookie jar, a default
Accept header. Find which one, and you have found the bug.