HTTP and logging
All traffic with the server goes through one internal class, Http\HttpClient. The folders(),
files() and uploads() resources only build the path and the body; they never see cURL, status
codes or answer parsing. Here is what that class does with every request.
Request path
resource (Files::get etc.)
│ method, path, query, body
▼
HttpClient::request() address, headers, timeouts, retries
▼
winter-cast → cURL the request itself
▼
HttpClient::send() one log line; not 2xx → ApiException; no answer → TransportException
▼
HttpClient::decode() JSON → Payload; not JSON → ResponseException
▼
Entity::fromPayload() typed fields; a wrong field → ResponseException
▼
File, Folder, Page …Headers
Every request gets:
| Header | Value |
|---|---|
Authorization |
Bearer {key} |
Accept |
application/json |
User-Agent |
WinterSDK/{version} or the value of S5wConfig::$userAgent |
Content-Type |
application/json for requests with a body; multipart/form-data for a single-request upload; application/octet-stream for chunks |
Upload-Offset |
only on upload chunks — the offset the chunk starts at |
User-Agent
By default — WinterSDK/{version}. The version comes from Composer
(InstalledVersions::getPrettyVersion('flytachi/winter-sdk-s5w')) and is computed once per process:
| How the package is installed | Header |
|---|---|
from a tag, e.g. v1.2.0 |
WinterSDK/1.2.0 |
| through a path repository | WinterSDK/dev-main |
| Composer does not know the package | WinterSDK/dev |
S5wConfig::$userAgent replaces the header entirely — e.g. so the server’s logs show your
application:
new S5wConfig($url, $key, userAgent: 'bravo-mobile/2.3'); // User-Agent: bravo-mobile/2.3Timeouts
| Timeout | Default | Which requests |
|---|---|---|
connectTimeout |
5 s | establishing the connection — every request |
timeout |
15 s | ordinary requests: the key check, folders, listing and reading files, rename, delete, opening a session and its status |
transferTimeout |
300 s | requests carrying file bytes: a single-request upload, sending a chunk, assembling the file |
The timeouts are set on every request from the configuration — including when you pass your
own CastClient with its own defaults.
Retries
What is retried on a network failure (no connection, a timeout) — and how many times:
| Request | Retries | Why |
|---|---|---|
| ordinary API requests | S5wConfig::$retries times (default 1 — no retries) |
pauses of 500 ms, 1 s, 2 s… ±30 % (winter-cast) |
| a single-request upload | never | a retry after a lost answer would store the file twice |
| opening a session with a hash | never | for the same reason: it can create the file itself |
| a chunk and assembling the file | up to Uploads::ATTEMPTS = 3 |
a mechanism of its own that asks the server first, see Upload pipeline |
Only network failures are retried. A server error answer (4xx, 5xx) on ordinary requests is never retried — it becomes an exception at once.
retries and delete
DELETE is not idempotent on the server. If the first attempt deleted the file and the answer was
lost, the retry gets 404 and delete() throws NotFoundException. With retries on, treat a 404
on delete as success.
Address and parameters
The address. baseUrl + '/' + path. baseUrl has spaces and a trailing / removed.
Path segments are encoded. A folder name (v1/folders/{name}) goes through rawurlencode():
spaces, non-Latin letters and special characters are safe. A file id is checked against its format
first (File::ID_PATTERN) — a string of the wrong shape never reaches the server and gives
NotFoundException at once. So ../ or ? cannot be slipped into a path through an id.
Query parameters. null values are not sent — that is “not given”. An empty string is sent as
it is: that is how Folder::ROOT becomes folder= (the root only). To keep an empty search: ''
from reaching the server, files()->list() turns it into null itself.
Errors and answer checks
A 2xx answer is success. The body is parsed as JSON; an empty body (204) is an empty result.
A non-2xx answer. message and errors are taken from the body; if there is no body or it is
not JSON — the text HTTP {code}. The exception class is chosen by status (ApiException::from()),
and the status, method, path and per-field errors are recorded in it. The message is
S5W {path}: {status} {text}, the path decoded.
No answer — TransportException, with the original exception in getPrevious().
An answer of the wrong shape. A body that is not JSON or not an object/array —
ResponseException. Then the entities read their fields through the internal Support\Payload,
which checks every field’s presence and type. The error names the path to the field:
S5W v1/check: bytes.quota must be int, got string
S5W v1/files: data[3].content.hash is missingSo a version mismatch, or a proxy answering with its own page, shows up at once and clearly — not
as a TypeError deep in the code.
Timestamps. RFC 3339 (2026-11-08T17:10:44+00:00), which S5W sends everywhere, is parsed
first; then the space-separated form older servers used (2026-11-08 17:10:44 +00:00); anything
else — including PostgreSQL’s raw 2026-11-08 17:10:44+00 and fractional seconds — through the
general parser.
Logging
The logger is passed to S5w and gets two kinds of records.
One line per request — at debug:
GET v1/check → 200 (21 ms)
POST v1/folders → 409 (28 ms)
GET v1/folders/нет такой → 404 (18 ms)
PATCH v1/uploads/16ffb6ab-… → 200 (890 ms)
GET v1/check → failed (0 ms): Connection failed: …The method, the decoded path, the status and the time; on a network failure — failed and the
cause. Status errors are logged at the same debug level, not warning: the SDK throws them as
exceptions anyway, and whether to log an error is the calling code’s decision. Otherwise one error
would end up in the log twice.
Chunked-upload events — debug, warning for retries, error for final failures. The full
list is in Upload pipeline. Only here does
the SDK write warning and error itself: it handles the retries internally and does not throw
them, so without a log nobody would know.
The messages do not start with S5W — the logger’s channel already says where a line comes from.
Exceptions do carry the S5W prefix: an exception may surface far away from the SDK.
Filling in values
The messages are written PSR-3 style, with placeholders: upload {id} started: {name}, …. But
PSR-3 only allows a logger to fill values from the context, it does not oblige it to:
winter-logger, and Monolog without PsrLogMessageProcessor, print {id} literally. So the SDK
fills the values itself, before handing the message to the logger (the internal
Support\InterpolatingLogger), and passes the context on unchanged:
[DEBUG] (S5W): upload b3b53a23-… started: backup.sql, 100725660 bytes in chunks of 8388608
{"id":"b3b53a23-…","name":"backup.sql","size":100725660,"chunk":8388608}The line reads right with any logger, and loggers that index fields (ELK and the like) get them separately. A logger that can fill values itself breaks nothing: the message has no placeholders left.
A full HTTP trace
If you need headers and bodies too, attach winter-cast’s middleware to your own client:
use Flytachi\Winter\Cast\Common\CastClient;
use Flytachi\Winter\Cast\Stereotype\LoggingMiddleware;
$client = (new CastClient())->addMiddleware(new LoggingMiddleware($logger));
$s5w = new S5w($config, client: $client, logger: $logger);The SDK’s own lines stay. Mind that a full trace may record what the SDK deliberately does not.
The key
The key is a secret, and the SDK makes sure it does not leak:
- it does not reach logs — headers are written neither in request lines nor in events;
- it does not reach debug output whole —
var_dump($config)andprint_r($config)shows5w_…aaaa(__debugInfo()); - it is not in exceptions — the message holds only the path and the server’s answer.
S5wConfig itself keeps the key in the public apiKey property — it is needed to send requests.
Do not serialise the configuration or hand it out.