Embedded HandbookApps
All sections › X. Design, networks and IoT
STM32F103, STM32F407 · level basic · 13 min read
Open in the interactive handbook

88. Distributed systems and APIs

A device's data rarely stops at the broker. From there it is taken in by a system of many parts: an ingestion service, a database, rules, notifications, reports. The parts live on different machines and talk to each other through APIs — programming interfaces over the network. The device becomes a participant in such a system too: it publishes to a broker (section 87) or calls the server API itself.

This section is about how such systems are built and what follows from that for the firmware: how to build a request so that it is understood, how to retry without doing harm, and what a token actually protects. The client side is written in portable C and checked by experiment: two nginx servers behind an nginx load balancer in containers.

Terms used in the documentation

Term Meaning
file server, client–server, multi-tier the file-server, client–server and layered architectures
vertical / horizontal scaling a bigger machine / more machines
replication, sharding read copies of a database, splitting data by key
monolith, microservices one application, a set of small services
API, REST, endpoint a programming interface, REST, an address you call
idempotent a repeat does not change the result
status code the response code
publish–subscribe, request–reply delivery through a broker, a call with an answer
round-robin in turn
exponential backoff, jitter a growing delay, a random spread
thundering herd everyone at once after a failure
JWT, bearer token a web token, a token in the Authorization header
OpenAPI (Swagger) a machine-readable API description

88.1 Information system architectures

Architecture Where processing happens Strength Weakness
file server on the client; the server only stores files simple the network moves whole files, simultaneous edits corrupt data
client–server on the server the client needs no power, the data is in one place the server is the center and a point of failure
multi-tier in tiers: ingestion, logic, database — on different machines each tier grows separately more parts — more configuration

Devices are almost always clients of a multi-tier system: a load balancer stands in front of the services, and the database behind them.


88.2 Scaling

A database is scaled in two ways. Replication — read copies: writes go to the primary, reads are spread across the copies. Splitting by key (sharding) — the data is divided into parts, for example by device number, and each part lives on its own machine.

Devices become more numerous, not more powerful, — so the systems for them grow horizontally.


88.3 Monolith and microservices

Monolith Microservices
structure one application, everything inside a set of small services, each does one thing
at the start easier to write and test as a whole more work on infrastructure
changes unpredictable: everything is connected within one service
load scales as a whole only the loaded service grows
failures everything goes down one service goes down — the rest live on
cost — a complex network of services that has to be monitored

For a device system the sensible path is to start with a monolith and split out services where the load or the team requires it: message ingestion, storage, rules and alarms, reports.


88.4 How systems agree

Ways to join systems, from simple to proper: by hand (copying), through shared files, through a shared database, through a data bus, and through an API over HTTP — REST or SOAP. For devices today it is almost always REST or messaging.

REST works with resources at addresses and with four main methods:

Method What it does Safe to retry?
GET /devices read the list of devices yes
POST /devices create a device record no — a retry creates a second one
PUT /devices/42 write device 42 as a whole yes
DELETE /devices/42 delete device 42 yes

The response is lost and the request is retried: POST created a second record, PUT left one

The property "a repeat does not change the result" is called idempotency, and for a device it is the main one. The link drops, the response is lost, the device repeats the request. A repeated POST creates a duplicate, a repeated PUT is harmless. So device data is written idempotently: PUT by the device's own number or POST with a unique request key by which the server recognizes a repeat.

The status code decides what to do next: 2xx — done; 4xx — the request is wrong, and repeating it in the same form is pointless; 5xx and no response — repeat, but with a growing delay (88.6). There are exceptions among the 4xx codes, and for a device they matter more than the rule: 429 "too many requests" and 408 "request timeout" are repeated with the same growing delay, and if the server sent a Retry-After header, — no earlier than the time it gives; 401 after the token expires — get a new token and repeat (88.7). A device that drops a request on 429 loses data.

Who waits for whom. A chain of synchronous calls "service → service → service" adds up the delays, and the failure of one link breaks the whole chain. An asynchronous request–reply answers "accepted" and sends the result later. Publish–subscribe is MQTT (sections 37.2 and 87): the device publishes, and it does not know and should not know who is subscribed or how many receivers there are.

Why devices do not poll the server every few seconds: polling once every 10 s is 8640 requests per day for one device, 8.64 million for a thousand devices, and almost all of them for nothing. A subscription or sending on an event spends the network and the battery only when there is something to say.


88.5 The device as a REST client

A request from the firmware is text (section 78.6), and two things in it break most often: the JSON body and its length.

JSON is built so that a quote in a string does not break the document:

/* Строка в JSON: кавычка, обратная косая и управляющие символы — через «\» */
size_t json_escape(char *out, size_t cap, const char *s)
{
    static const char hex[] = "0123456789abcdef";
    size_t k = 0;
    for (; *s; s++) {
        unsigned char c = (unsigned char)*s;
        char tmp[7];
        size_t n = 0;
        if (c == '"' || c == '\\') {
            tmp[n++] = '\\';
            tmp[n++] = (char)c;
        } else if (c < 0x20) {
            tmp[n++] = '\\';
            tmp[n++] = 'u';
            tmp[n++] = '0';
            tmp[n++] = '0';
            tmp[n++] = hex[c >> 4];
            tmp[n++] = hex[c & 15];
        } else {
            tmp[n++] = (char)c;
        }
        size_t need = n;
        if (c >= 0xC0u)                     /* первый байт буквы UTF-8: она должна влезть целиком */
            need = c >= 0xF0u ? 4u : c >= 0xE0u ? 3u : 2u;
        if (k + need + 1 > cap)
            break;                          /* не влезло — обрезаем, букву UTF-8 не разрезая */
        memcpy(out + k, tmp, n);
        k += n;
    }
    out[k] = '\0';
    return k;
}

/* Телеметрия: температура в сотых долях градуса — без float на приборе */
int json_telemetry(char *out, size_t cap, const char *device, int32_t temp_centi, uint32_t uptime_s)
{
    char name[64];
    json_escape(name, sizeof name, device);
    int32_t a = temp_centi < 0 ? -temp_centi : temp_centi;
    return snprintf(out, cap, "{\"device\":\"%s\",\"temp\":%s%ld.%02ld,\"uptime\":%lu}",
                    name, temp_centi < 0 ? "-" : "", (long)(a / 100), (long)(a % 100),
                    (unsigned long)uptime_s);
}

For a device named stm32 "цех-1", with a temperature of −1234 hundredths of a degree and an uptime of 86400 s, the result was {"device":"stm32 \"цех-1\"","temp":-12.34,"uptime":86400} — Python parsed it without errors. The fraction is printed from integers, without floating-point arithmetic (section 69).

The body length is in bytes, not in characters: a Cyrillic letter in UTF-8 is two bytes. strlen counts exactly bytes:

int http_request(char *out, size_t cap, const char *method, const char *host,
                 const char *path, const char *body)
{
    if (body)
        return snprintf(out, cap,
                        "%s %s HTTP/1.1\r\nHost: %s\r\n"
                        "Content-Type: application/json\r\nContent-Length: %zu\r\n"
                        "Connection: close\r\n\r\n%s",
                        method, path, host, strlen(body), body);
    return snprintf(out, cap, "%s %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n",
                    method, path, host);
}

Experiment. On the container network there are two nginx servers, backend-1 and backend-2, and in front of them an nginx load balancer named lb. The "device" is the same code built for a computer:

What the device sent What it got
four GET /whoami backend-1, backend-2, backend-1, backend-2 — in turn
POST /telemetry with JSON 201; the server saw Content-Length 60 — exactly the body length in bytes

The load balancer takes a few lines:

upstream backends {
    zone backends 64k;
    server b1:80;
    server b2:80;
}
server {
    listen 80;
    location / {
        proxy_pass http://backends;
    }
}

⚠️ The zone line here is not for decoration. Each nginx worker process keeps its own round-robin queue, and connections from devices are short and land in different processes. In the first try, without a shared zone, all four requests went to the first server, and the load balancer looked broken. zone makes the group state shared by all processes.

The stack frame of json_telemetry on Cortex-M3 is 136 bytes, 64 of them the name buffer; the other functions get by with 48 bytes or less. For an RTOS task that is not much, but the stack of the communication task is sized with a margin (section 26).


88.6 Retry, but not all at once

The server went down and came back up. Thousands of devices that were waiting will repeat the request — and if all of them do it after exactly one second, they will bring the server down again. This is the "thundering herd". The cure is a growing delay with a random spread (jitter):

/* Задержка перед попыткой attempt (0, 1, 2…): случайная в пределах
 * 1 с · 2^attempt, но не больше 5 минут. */
uint32_t backoff_ms(unsigned attempt, uint32_t *seed)
{
    uint32_t cap = attempt >= 9 ? 300000u : 1000u << attempt;
    if (cap > 300000u)
        cap = 300000u;
    *seed = *seed * 1103515245u + 12345u;
    return (*seed >> 8) % (cap + 1);
}

The delay limit grows — 1, 2, 4, 8… 256 s — and from the tenth attempt it hits 5 minutes; within the limit the delay is random. Calculated for 10000 devices after a failure: without the spread all 10000 arrive in the same second, and with the spread, on the fifth attempt, the requests stretch over 16 s: 62 per 0.1 second on average, about 90 in the densest 0.1 s. Each device takes its own seed for the generator — for example, from the unique ID of the chip (section 79.7), otherwise the "random" delays will be the same for everyone.

Retry, but not all at once: the limit doubles up to 5 minutes, and within it the delay is random


88.7 Tokens: what they protect and what they do not

The server has to know who has come to it. The device presents a token in the Authorization: Bearer … header. A common format is JWT: three parts separated by dots — header, payload and signature, each encoded as base64url:

/* base64url без «=» в конце — так кодируют части токена JWT */
size_t b64url(const uint8_t *in, size_t n, char *out)
{
    static const char tab[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    size_t k = 0;
    for (size_t i = 0; i < n; i += 3) {
        uint32_t v = (uint32_t)in[i] << 16;
        if (i + 1 < n)
            v |= (uint32_t)in[i + 1] << 8;
        if (i + 2 < n)
            v |= in[i + 2];
        out[k++] = tab[v >> 18 & 63];
        out[k++] = tab[v >> 12 & 63];
        if (i + 1 < n)
            out[k++] = tab[v >> 6 & 63];
        if (i + 2 < n)
            out[k++] = tab[v & 63];
    }
    out[k] = '\0';
    return k;
}

The header {"alg":"HS256","typ":"JWT"} turns into eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 — the start of every such token.

The main thing to remember: base64 is an encoding, not a cipher. Anyone who has the token can read its contents. The token is protected against forgery by the signature, and against eavesdropping only by encrypting the channel (TLS, section 37.4). The payload holds an expiry time: the server will not accept an expired token, and the device has to get a new one in time. A token stored in plain text in the firmware is visible in its image (section 77.9).

Before writing the client side, you try the API by hand: a description in the OpenAPI (Swagger) format shows all the methods, and tools for sending requests let you see the server's responses before the first line of firmware.


88.8 Examples of distributed technologies


88.9 Pitfalls

Duplicate measurements in the database. The device repeated a POST after a dropped link; you need a PUT by the device's own number or a request key.

The server cannot come back up after a failure. All devices repeat their requests at the same time — there is no random spread, or everyone has the same seed.

The server answers 400 to the telemetry of one device. The device name contains a quote, and the JSON was built without escaping.

The request body is cut off on Cyrillic text. Content-Length was counted in characters, not in bytes.

The load balancer sends everything to one server. There is no shared zone — each nginx process has its own queue.

The token was stolen from intercepted traffic. base64 does not encrypt; you need TLS.

The device stopped sending data after a day. The token expired, and the firmware cannot renew it.

A report takes minutes to build. A chain of synchronous service calls, where each one waits for the next.

The server bill went up, but there is no more data. Thousands of devices poll the server every few seconds instead of subscribing.


88.10 Summary

Next: section 89 "Network protocols and mesh networks".

Open in the interactive handbook