errno

curl exits 3 on a Jenkins API call: square brackets in tree= are not URL-safe

· in APIs and automation · tested on Jenkins LTS REST API, curl 8.x, API-token authentication

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 0curl 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.

jenkins curl api ci