NAME
API::Docker::Role::HTTP - HTTP transport role for Docker Engine API
VERSION
version 0.004
SYNOPSIS
package MyDockerClient;
use Moo;
has host => (is => 'ro', required => 1);
has api_version => (is => 'ro');
has tls => (is => 'ro', default => 0);
has cert_path => (is => 'ro');
has tls_insecure => (is => 'ro', default => 0);
with 'API::Docker::Role::HTTP';
# Now use get, post, put, delete_request, head methods
my $data = $self->get('/containers/json');
DESCRIPTION
This role provides HTTP transport for the Docker Engine API. It implements HTTP/1.1 communication over Unix sockets and TCP sockets without depending on heavy HTTP client libraries like LWP.
Features:
Unix socket transport (
unix://...)TCP socket transport (
tcp://host:port), in the clear or over TLS with client certificates ("TLS on a tcp:// connection")HTTP/1.1 chunked transfer encoding
Automatic JSON encoding/decoding
Newline-delimited JSON event streams (
ndjson => 1), including the failures the engine reports inside an HTTP 200 bodyDemultiplexing of the Docker stream format ("stream_frames")
Incremental delivery of a response through a per-request callback, so the endpoints that never close are usable at all ("Streaming a response as it arrives")
Request/response logging via Log::Any
Automatic connection management
Consuming classes must provide host, api_version, tls, cert_path and tls_insecure attributes. The last three are read only by the tcp:// branch of the socket builder, and only when TLS is asked for, but the contract is stated once rather than probed for at connect time.
A unix:// connection is a local socket with no wire to protect and is never encrypted; it ignores all three attributes, and API::Docker refuses the combination at construction rather than letting a request for an encrypted transport be answered with an unencrypted one. A tcp:// connection is plaintext unless tls => 1, which is the whole of the difference -- see "TLS on a tcp:// connection".
TLS on a tcp:// connection
tls => 1 replaces the IO::Socket::INET connection with an IO::Socket::SSL one and changes nothing else: the same request writer, the same reader, the same everything above the socket.
my $docker = API::Docker->new(
host => 'tcp://dockerhost:2376',
tls => 1,
cert_path => '/home/me/.docker',
);
What the certificates are, and where
cert_path names a directory in the layout the docker CLI writes, and each of the three files is used if it is there:
ca.pem - the trust anchor the daemon's certificate is checked against
cert.pem and key.pem - this client's certificate and private key, sent when the daemon asks the client to identify itself
The two halves of the client certificate go together: one of them present without the other is a croak, because a key with no certificate proves nothing and a certificate with no key cannot be used. A directory holding only ca.pem is fine -- that is a daemon this client verifies but does not authenticate to. A cert_path that names nothing is a croak: it is read only once TLS was asked for, and at that point a path pointing nowhere means the caller believes certificates are in use that are not.
cert_path defaults from DOCKER_CERT_PATH, so on a machine that also runs the docker CLI it arrives set. Without tls => 1 nothing reads it, so that costs nothing; with it, pass cert_path => undef to use the system trust store instead of the CLI's private one.
TLS with no certificates at all
It means encrypt and verify against the system trust store, not an error.
tls asks for a connection that is encrypted and whose far end is authenticated. It does not ask to authenticate this client, which is what the files on disk are for, and treating the absence of a client certificate as a missing precondition would conflate the two. The deployment with no certificate files is real, and is the one this role's documentation used to recommend before there was any TLS here: a terminator -- nginx, stunnel, Traefik -- in front of the daemon, holding a publicly trusted certificate. There is nothing for a cert_path to point at in that setup.
It is also the safe reading rather than the lax one: verification stays on either way, so the mode reached by configuring nothing is the verifying mode. A stock dockerd --tlsverify uses a private CA that the system store does not have, and such a connection fails with a verification error naming exactly that -- which is the intended outcome, not a silent downgrade. Point cert_path at the directory holding its ca.pem and it verifies.
Turning verification off
tls_insecure => 1, and the name is the whole of the warning. It sets SSL_VERIFY_NONE and switches the hostname check off, which leaves a connection that is encrypted against a passive listener and against nothing else: whoever answers chooses the certificate, so anyone able to redirect the connection reads and rewrites everything on it -- registry credentials, image contents, the commands containers are started with.
It exists for a self-signed daemon certificate whose CA is genuinely not to hand. The better answer to that is nearly always ca.pem: a self-signed certificate is its own CA and can be used as the anchor directly.
The dependency
IO::Socket::SSL is a recommended, not a required, dependency, and it is loaded at the moment the first TLS connection is opened. It brings in Net::SSLeay, which is XS compiled against libssl, and the unix:// transport -- local Docker, rootless Podman, the default -- never needs any of it; requiring it would make this client unbuildable on a machine with no OpenSSL headers for the sake of a transport it is not using. Without it, tls => 1 croaks naming the module and how to install it, at the same point every other connection failure is reported.
read_timeout
Seconds of silence after which a request gives up and croaks with an API::Docker::Error::Timeout. undef -- the default, and what every existing caller gets -- means no timeout at all and is the behaviour this distribution has always had. 0 means the same and is the way to say it explicitly, so a client carrying a default can be opted out of per request.
my $docker = API::Docker->new(read_timeout => 30);
$docker->system->using(read_timeout => 0)->events; # this one may wait
Per request it is an option of "get", "post", "put", "delete_request" and "head". A resource class carries it through "using" in API::Docker::Role::Using, which clones the class rather than taking it per method -- up for a slow endpoint, down for a stream that should not stall, off with 0.
See "Bounding a request that never ends" for what it does and does not cover, and "What a timeout covers" in API::Docker for the same question asked of both bounds at once.
connect_timeout
Seconds after which opening the connection gives up and croaks with an API::Docker::Error::Timeout whose ->phase is 'connect'. undef -- the default, and what every existing caller gets -- means no bound and is the behaviour this distribution has always had; 0 means the same and is the way to say it explicitly.
my $docker = API::Docker->new(connect_timeout => 5);
$docker->system->using(connect_timeout => 0)->version; # may wait
Separate from "read_timeout" rather than folded into it, because the two bound different things and want different numbers: a connect is either immediate or broken, while a read is waiting on work the daemon has to do.
Per request it is an option of "get", "post", "put", "delete_request" and "head". A resource class carries it through "using" in API::Docker::Role::Using. See "Bounding the connection itself" for what it does on each transport, which is not the same thing on all three.
get
my $data = $client->get($path, %opts);
Perform HTTP GET request. Returns decoded JSON or raw response body.
Options:
params- HashRef of query parameters; a HashRef value is JSON-encodedheaders- HashRef of extra HTTP headers, e.g.{ 'X-Registry-Auth' => $b64 }ndjson- Parse the body as newline-delimited JSON and always return an ArrayRef of events, even for a stream carrying a single object. Named for the format rather thanstream, which is already a query parameter of/eventsand/containers/{id}/stats. AnerrorDetailevent in such a stream croaks; see "Failure inside a 200 response"croak_on_error- Default true, and only consulted withndjson => 1. Set it false for a stream whose objects are engine data rather than the outcome of one operation --/eventsis the only such endpoint hereraw- Never decode the body; return the response bytes verbatimresponse- HashRef the status line and the response headers are written into; see "Reading the status line and the response headers"on_event,on_frame,on_chunk- CodeRef called with each unit of the response as it arrives, instead of the body being buffered and returned. At most one of the three; see "Streaming a response as it arrives"read_timeout- Seconds of silence after which this request gives up and croaks with an API::Docker::Error::Timeout. Overrides the "read_timeout" attribute;0means no timeout. See "Bounding a request that never ends"connect_timeout- Seconds after which opening the connection gives up and croaks with an API::Docker::Error::Timeout whose->phaseis'connect'. Overrides the "connect_timeout" attribute;0means no bound. See "Bounding the connection itself"headersnames are validated, not sanitised; see "Header names are rejected, header values are stripped"
Bounding a request that never ends
Nothing above stops a request waiting forever. Connection: close asks the daemon to hang up when it is done, and the readers wait for that -- so a daemon that has nothing more to send and does not hang up leaves the client blocked with no way out. That is not hypothetical: attaching to a container that has already exited answers, delivers the buffered frames and then holds the connection open indefinitely on rootless Podman (karr k52), and /containers/{id}/stats opened on a running container does not end when that container exits on Docker -- it degrades into zero-filled readings and keeps going (karr k59).
"read_timeout" bounds that:
# Give up after two seconds of silence rather than waiting forever.
my $frames = $docker->containers->using(read_timeout => 2)->attach($id);
It is an idle timeout, not a deadline
The clock measures the time since the last byte arrived, not the time since the request started. A stream that keeps producing runs as long as it likes; one that stops producing is cut off. That distinction is the whole point -- both hangs above deliver data first and stall afterwards, so a bound on the total time would have to be set longer than any legitimate stream, and a bound on the time to the first byte would never fire at all.
There is no default, and no per-endpoint default either
Off unless asked for, everywhere. Whether a silence is a stall or normal is a property of the workload rather than of the endpoint: /build with a large context is legitimately quiet for as long as /events is, and a built-in default on attach would kill a perfectly healthy session at an idle shell prompt. So no existing call changes behaviour, and picking the number is the caller's -- who is the only one who knows what the request is for.
For the two endpoints above, if you want a figure to start from: a couple of seconds is right for attach or logs used to collect what is already there, and something above the daemon's own emit interval -- Docker sends a stats reading about once a second -- for stats.
What happens when it expires
The request croaks, on every path, with an API::Docker::Error::Timeout. It never returns a truncated response: a short body satisfies every return shape this role promises and would be indistinguishable from a complete one. The exception carries what did arrive -- ->partial for a buffered request, ->summary for a streamed one -- so collecting what there is and then stopping is an eval:
my $out = '';
eval {
$docker->containers->using(read_timeout => 2)->attach($id,
on_frame => sub { $out .= $_[0]{data} });
};
die $@ if $@ && !(ref $@
&& $@->isa('API::Docker::Error::Timeout'));
That class's own documentation has the reasoning for why this is fatal even where the caller already holds every unit.
What it does not cover
Only reading. Connecting is bounded separately by "connect_timeout", and writing the request is not bounded at all -- which matters only for a large /build context sent to a daemon that has stopped reading.
It is implemented with SO_RCVTIMEO on the socket, which was measured to behave the same over unix://, plain tcp:// and TLS: the timeout fires, the handle is not left unusable, and reading afterwards works. The struct timeval it is set with was measured on Linux; on Windows the millisecond DWORD Winsock documents is sent instead, which is reasoned rather than measured. A platform that rejects either croaks rather than continuing without the bound.
Over TLS it is not quite an idle timer on the plaintext. SO_RCVTIMEO bounds each blocking receive on the underlying socket, and one plaintext read can consume several of those while a TLS record arrives in pieces -- so a record dribbling in slowly enough resets the clock without a byte reaching the caller. It still bounds the hang, which is what it is for.
Bounding the connection itself
"connect_timeout" is the other half, and it is off by default for the same reason: nothing here changes behaviour unless it is asked for.
my $docker = API::Docker->new(connect_timeout => 5, read_timeout => 30);
What it does is not the same on all three transports, and the difference was measured rather than assumed:
tcp://-- a real bound. Against a host that drops SYNs, an unbounded connect waits for the kernel's own timeout, which on Linux is over two minutes;connect_timeout => 2gave up after 2.00s. This is the case the option exists for.unix://-- a bound, but it does not wait. A connect to a Unix socket whose listen backlog is full blocks: measured against a listener withListen => 1and nobody accepting, still blocked after 8 seconds. With aconnect_timeoutset it fails at once instead, withEAGAIN-- becauseIO::Socketperforms a timed connect non-blocking, and anAF_UNIXconnect has no in-progress state to wait on. So the hang is gone, at the price of not tolerating even a momentary backlog. A socket path that does not exist isENOENTeither way and is not affected.TLS -- bounds the TCP connect only. The handshake that follows it runs on the connected socket, before "read_timeout"'s
SO_RCVTIMEOis applied, and is not covered by either.
An expiry croaks with an API::Docker::Error::Timeout carrying ->phase 'connect', ->timeout the value that expired and an empty ->partial -- there is no response to have part of. Every other connect failure croaks with the plain string it always did: a refused connection, a missing socket path and a rejected certificate are diagnoses, not timeouts, and rewriting them as one would name a cause the caller cannot act on.
Streaming a response as it arrives
Without one of these options a request is read whole, then parsed. That is right for a request/response endpoint and wrong for every endpoint whose point is that it keeps going: logs(follow => 1), /events with no until and /containers/{id}/stats with no stream => 0 never return, because the daemon never closes and there is nothing else to wait for.
A callback is half the answer -- it decides what to do with each unit, and it can stop. "Bounding a request that never ends" is the other half, for the stream that stops arriving without ever ending.
Pass a callback and the body is handed over piece by piece instead:
my $summary = $client->get('/events',
croak_on_error => 0,
on_event => sub {
my ($event, $stop) = @_;
print $event->{status}, "\n";
$stop->() if $event->{status} eq 'destroy';
},
);
$summary; # { delivered => 7, stopped => 1 }
One unit per call, and three units to choose from
The engine's streaming endpoints do not share a natural unit, so there is an option per unit and a request picks one:
on_event- one decoded HashRef per newline-delimited JSON object. For/eventsand the/build,/images/create,/images/*/pushprogress streamson_frame- one{ stream => ..., data => ... }HashRef per demultiplexed frame of the Docker stream format. For/containers/{id}/logsand/exec/{id}/start; normally reached through "stream_frames" rather than directlyon_chunk- the response bytes as they arrive, undecoded and unbuffered. For an image export, and for anything with no structure this role knows about
Passing two of them croaks before the request is sent: they are three shapes different endpoints have, not three views of one stream.
Saying stop
The callback is called as $cb->($unit, $stop) and its return value is ignored. To end the stream it calls $stop->(); _request checks after the callback returns, delivers nothing further, and comes back.
An explicit closure rather than a return value, because every truthiness convention has a silent failure mode here. sub { push @got, $_[0] } returns a count and sub { $last = $event->{status} } returns whatever the engine said -- and a container event's status is literally stop. Under either polarity one of those ends the stream by accident and hands back a truncated one with nothing to show for it. A closure the caller has to invoke cannot be produced by accident.
What comes back
A streamed request returns a summary HashRef, not the body:
{ delivered => 7, stopped => 1 }
delivered is how many units went to the callback; stopped is 1 when the callback ended the stream and 0 when the daemon did. Nothing is accumulated along the way -- an unbounded feed must not cost memory in proportion to how long it runs, and the caller has been handed every unit already. What it could not otherwise know is how the stream ended, and that is what the summary says.
Only a complete unit is buffered while it is still arriving: the current ndjson line or the current frame. A line, and equally an 8-byte frame header, can be split across two chunks or two reads, so partial ones are carried forward rather than decoded early.
How often the callback is called
Once per unit the daemon has finished sending, as soon as the bytes that complete it have arrived -- not once per read of a fixed size, and not once at the end.
That is worth stating because it was not true before karr k60. The reads were read(), which is fread-shaped: it loops until it has the length it was asked for or the stream ends, rather than returning what has arrived. On the raw-stream endpoints -- attach, logs(follow => 1), exec/start, which carry neither a Content-Length nor chunked encoding -- the reader asks for 64K, so nothing reached the callback until 64K had accumulated or the daemon hung up. On a stream that never ends, nothing reached it at all.
Measured on an AF_UNIX socket pair with no daemon involved, a peer writing three frames 0.15s apart and then closing:
before: 1 call at 0.45s (the moment it closed)
after: 3 calls at 0.15s, 0.30s, 0.45s
Every read now goes through one sysread and a buffer this role keeps itself, which is also why the status line and the headers are read the same way: PerlIO's read-ahead put the first bytes of the body somewhere the body reader could not get at them, so the header reads had to move too or those bytes would have been dropped.
What is not streamed
A response with status >= 400 is read whole and croaked with as always: it is a short JSON object naming a failure, not a stream, and the callback never sees it. response is still filled. A HEAD response has no body, so a callback on one is never called and undef comes back as usual.
With on_event, croak_on_error works as it does for ndjson -- except that the check runs per event, so a failed build croaks at the event that reports it instead of when the daemon eventually closes. The API::Docker::Error::Stream then carries that one event in ->events rather than the whole stream: the callback was handed the rest as it arrived, and none of it was kept.
on_frame requires the stream to be framed. The buffered path decides framing by walking the whole body (see "Detecting a framed stream"), which is exactly what a streamed one does not have, so an unframed stream has to declare itself with tty => 1 to "stream_frames" and an undeclared one that turns out not to be framed croaks. A stream the daemon cuts off mid-frame croaks too -- there is no whole body left to fall back to raw with. Neither applies after a $stop->(), which leaves a partial unit in the buffer by construction.
Reading the status line and the response headers
The return value is the decoded body and nothing else, which leaves two things the engine said unreachable: the status code, and the response headers. Pass a HashRef as response to get them:
my %res;
my $data = $client->post("/containers/$id/start", undef,
response => \%res);
$res{status}; # 204
$res{reason}; # 'No Content'
$res{headers}{'api-version'}; # header names are lowercased
The hash is overwritten on every call and filled before the >= 400 croak, so a caller that wraps the request in eval can still read the status of a failed one. The return value is unaffected, so passing response never changes what a method hands back.
Two things need it. The engine answers a state change that did nothing with 304 Not Modified -- starting a running container, stopping a stopped one -- which carries no body, exactly like the 204 of a change that did happen; see "start" in API::Docker::API::Containers. And HEAD /containers/{id}/archive carries its whole payload in the X-Docker-Container-Path-Stat header, with no body to return at all.
Failure on the status line
A status of 400 or above croaks with an API::Docker::Error::HTTP. The message is the engine's message field, its errorDetail.message, its flat error key or the raw body, in that order of preference, wrapped as Docker API error (STATUS): REASON -- the same text this croak has always carried, and the object stringifies to it byte for byte, Carp's location suffix included. Code that catches $@ as a string cannot tell the difference and needs no change.
What the object adds is $err->status. The message is engine-specific prose: killing a stopped container answers 409 with can only kill running containers ... container state improper on rootless Podman 5.4.2, while Docker's own example for that case reads Container <id> is not running. Anything that had to tell "no such container" from "wrong state" apart was matching on that prose; the status code is the same distinction without it. ->reason, ->body and ->data carry the rest of what the engine said.
This is not a replacement for the response option above, which stays the only way to the status of a request that did not fail -- a 304, or a header carrying the whole payload of a successful HEAD.
Failure inside a 200 response
/build, /images/create (pull) and /images/{name}/push report a failed operation as an errorDetail object inside a stream the daemon already answered with HTTP 200. The status line is committed before the operation is attempted, so the >= 400 check above cannot see it, and a client that trusts the status hands a broken build back as a success.
So an ndjson => 1 request scans the decoded events and croaks with an API::Docker::Error::Stream the moment one carries errorDetail. That object stringifies to the reason plus Carp's usual location suffix, so eval-and-inspect-$@ code cannot tell it from the plain croak it replaces; $err->events carries the complete event list, so the progress output that led up to the failure is not lost with the return value.
The trigger is the errorDetail key alone. The flat error key the engine sends beside it holds the same text and is used only as a fallback message, never as the trigger on its own.
croak_on_error => 0 turns the scan off for a stream that is a feed rather than an operation. The check is on by default, and opting out is per endpoint, because the set of operation-shaped streaming endpoints is open-ended while the feed-shaped ones are /events and nothing else: a new endpoint added without a thought about this gets the loud behaviour, not the silent one.
Failure in the middle of a response
The daemon can also stop saying anything in the middle of saying it. A status line with no terminator, a header block with no blank line to close it, a body shorter than its Content-Length, a chunk shorter than its own header, a chunk header cut in half, a chunked body with no terminating zero chunk: each of those is a response that ended before it was finished, and each croaks with an API::Docker::Error::Truncated.
my $tar = eval { $docker->images->get_tar('busybox') };
die $@ if $@ && !(ref $@
&& $@->isa('API::Docker::Error::Truncated'));
It is a structural check, so it needs no option, applies to every request, and cannot fire on a response that is complete. Which question it asks depends on how the piece is framed: where the response announced a length, what arrived is compared against it; where the framing is by terminator instead -- the head and the chunk headers -- it asks whether the terminator came before the stream ended, which is decidable without anything to compare. The exception carries what did arrive: ->partial for a buffered request, ->summary for a streamed one, and ->phase for which piece of the framing ran out.
This is a behaviour change and not a bug fix in passing. Until it existed every shape above was returned rather than raised, and none of them was distinguishable from a complete response: ndjson gave a shorter ArrayRef, raw gave fewer bytes, the default gave whatever the truncated bytes happened to parse as. A cut head was quieter still -- the response was read on with whichever headers had arrived, and one cut before Content-Length and Transfer-Encoding left neither, which is the close-delimited path below, where an EOF is the legitimate end and nothing looks wrong. Code that was silently receiving half a response now gets an exception where it used to get a value.
The one thing here that is not raised as an object: a connection that closed without a single byte of a status line still croaks with the plain No response from Docker daemon string it always has. Nothing about it was ever silent, and it is a message callers may be matching on.
Where an end of stream is still the end
A body delimited by nothing but the close. attach, logs(follow => 1), /exec/{id}/start -- the whole application/vnd.docker.raw-stream family -- carry neither a Content-Length nor chunked encoding, so the response announces no end and there is nothing for a short one to be short of. That is how every one of them finishes, and treating it as truncation would break all of them.
Their heads are another matter and are checked like every other head. An engine writes those two by hand rather than through its HTTP server, so it is worth saying that they are well-formed: both answer with HTTP/1.1 200 OK, a single Content-Type line and the blank line, measured on Docker 29.7.2 and on rootless Podman 5.8.4. So does every other shape either of them produces -- 200, 204, 304, HEAD, chunked. Nothing legitimate ends a head without its blank line.
The same goes for a stream a callback ended with $stop->(): the rest of the response is unread because the caller said so, and every check on the streaming path is skipped once it has.
Against a timeout, and against a status
API::Docker::Error::Timeout is the daemon going quiet for longer than a bound the caller asked for; this is the daemon closing mid-response, and needs no bound to be noticed. The two share a contract -- neither ever returns a short body, and both hand over what arrived -- and are separate classes because only one of them is about an option, and only one of them can fire on a response that would have completed.
A response whose status is 400 or above raises this rather than an API::Docker::Error::HTTP when it is its body that was cut short, which is the rule the timeout already follows in the same place: the transport cannot tell a caller what the engine said when it did not finish saying it. ->partial holds the part of the error body that did arrive.
Header names are rejected, header values are stripped
A CR or LF in a header value is stripped and the value is flattened onto its own line. A header name that is not an RFC 9110 token is refused with a croak instead.
The asymmetry is deliberate. A value can pick up a stray newline honestly -- MIME::Base64::encode_base64 wraps its output by default, and a token pasted out of a file brings its line ending along -- and flattening it preserves what the caller meant. A name is a literal the programmer wrote; there is no benign way for one to contain CR, LF, a space or a colon, and quietly rewriting "X-Foo\r\nX-Bar" into X-FooX-Bar would put a header on the wire under a name nobody asked for. Validating against the token grammar also catches the separators that would corrupt the request without injecting anything.
A request path is rejected, not sanitised
The $path given to "get", "post", "put", "delete_request", "head" and _request is spliced straight into the request line as $method /v$version$path HTTP/1.1, and it carries caller data: the resource methods build it by interpolation -- "/containers/$id/json", "/images/$name/push" -- so a container name or an image reference the user typed ends up in the request line unescaped. A byte the line's own grammar reads therefore rewrites the request rather than naming a resource: a CR or LF ends the line and opens a header of its own, a space starts the HTTP-version field, and a ? or # opens the query string or fragment.
So the path is checked against the RFC 3986 origin-form character set -- unreserved, the sub-delims, and : @ % /, which is the set an image reference lives in -- and a path outside it is refused with a croak before anything reaches the wire, the same treatment and for the same reason a header name gets. Sanitising is not on the table here: percent-encoding the path at this layer cannot tell a separator from data, so it would either mangle every / and : or leave the injection open. Query parameters belong in params, which is assembled separately and runs each element through _uri_encode.
post
my $data = $client->post($path, $body, %opts);
Perform HTTP POST request. $body is automatically JSON-encoded if provided.
Options: params, headers, ndjson, croak_on_error, raw, response and the on_event/on_frame/on_chunk callbacks as for "get", plus raw_body and content_type for sending a non-JSON payload such as a build context tarball.
put
my $data = $client->put($path, $body, %opts);
Perform HTTP PUT request. $body is automatically JSON-encoded if provided.
Options: params, headers, ndjson, croak_on_error, raw, response and the on_event/on_frame/on_chunk callbacks as for "get", plus raw_body and content_type for sending a non-JSON payload -- containers->put_archive uses both to send a tar stream.
delete_request
my $data = $client->delete_request($path, %opts);
Perform HTTP DELETE request.
Options: params (hashref of query parameters).
head
my %res;
$client->head("/containers/$id/archive",
params => { path => '/etc/hostname' },
response => \%res,
);
my $stat = decode_json(decode_base64($res{headers}{'x-docker-container-path-stat'}));
Perform HTTP HEAD request. Always returns undef: a HEAD response has no body by definition, so everything it says is in the status line and the headers, and response is the only way to reach them.
The body is not read even when the response announces one. A HEAD response repeats the header fields the equivalent GET would send, Content-Length among them, and then sends nothing -- reading it would block on bytes that never arrive. Measured against Podman 5.4.2 (API 1.41), HEAD /containers/{id}/archive in fact announces no length at all, only X-Docker-Container-Path-Stat -- but an engine that does announce one is not waited on either.
Options: params, headers and response as for "get".
stream_frames
my $frames = $client->stream_frames('GET', "/containers/$id/logs", %opts);
Perform a request against one of the engine's framed endpoints (/containers/{id}/logs, /exec/{id}/start) and return an ArrayRef of frames:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
stream is stdout, stderr or stdin for a multiplexed stream, and raw for an unframed one. It is always a plain string, so callers never need a defined-check. Joining the payloads gives the plain text:
my $text = join '', map { $_->{data} } @$frames;
The response body is never JSON-decoded, so a container printing JSON lines is returned verbatim.
Options are those of _request (params, body, headers), plus:
tty- Skip demultiplexing and return the body as a singlerawframe. Set it when the container or exec instance was created with a TTY and its output is binary; see "Detecting a framed stream" for why.on_frame- CodeRef called with each frame as it arrives instead of the whole ArrayRef being returned at the end; see below.
Following a framed stream
With on_frame the frames are handed over as they arrive and the return value is the summary HashRef described in "Streaming a response as it arrives", not an ArrayRef:
my $summary = $client->stream_frames('GET', "/containers/$id/logs",
params => { follow => 1, stdout => 1, stderr => 1 },
on_frame => sub {
my ($frame, $stop) = @_;
print $frame->{data};
$stop->() if $frame->{data} =~ /listening on/;
},
);
This is the only way to use follow => 1 at all: without it the request does not return until the container exits.
The frame shape is the same either way, tty included -- a TTY stream arrives as a series of { stream => 'raw', ... } frames rather than the single one the buffered path builds, so a caller still never branches on it.
tty is a declaration here rather than the hint it is on the buffered path. Deciding framing from the bytes needs the whole body, which is precisely what is not being kept; so an unframed stream must say so, and one that does not and is not framed croaks instead of inventing frames from its payload.
Detecting a framed stream
A container created without a TTY produces the Docker stream format -- an 8-byte header per frame (byte 0 the stream type, bytes 4-7 a big-endian uint32 payload length) followed by that many payload bytes. With a TTY there is no header and the payload is raw pty output.
The engine is supposed to distinguish the two with the response Content-Type (application/vnd.docker.multiplexed-stream against application/vnd.docker.raw-stream), but that signal is not dependable. Measured against Podman 5.4.2 (API 1.41): GET /containers/{id}/logs sends no Content-Type at all, for either kind of container, and POST /exec/{id}/start sends application/vnd.docker.raw-stream for both -- including the non-TTY exec whose body is in fact multiplexed. Trusting the header would therefore hand frame headers to the caller on that engine.
The framing is decided from the bytes instead. The body is walked as frames: each header must have a stream type of 0, 1 or 2, three zero bytes after it, and a payload length that leaves at least that many bytes in the buffer. The body is treated as framed only when the walk consumes it exactly and yields at least one frame; anything else is returned as a single raw frame.
This can be fooled in one direction only. Raw TTY output is misread as framed if it begins with a byte no greater than 0x02, followed by three NUL bytes and a length that happens to chain exactly to the end of the body. Text output cannot do that -- a printable character is 0x20 or above -- so it takes binary output from a TTY-allocated container. Pass tty => 1 for that case. The reverse mistake cannot happen silently: a genuine frame stream is only ever reported as raw when its final frame is truncated, which needs the daemon to close the connection mid-frame.
SEE ALSO
API::Docker - Main client using this role
API::Docker::Error::HTTP - Raised for a status of 400 or above; carries the status code
API::Docker::Error::Stream - Raised for a failure reported inside a 200 event stream
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-api-docker/issues.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.