๐Ÿšš WIA Delivery Robot Standard Ebook | Chapter 4 of 8


Chapter 4: Phase 2 API Interface โ€” Auth, Robots, Packages, Routes and Fleet

Chapter 3 fixed what a robot, a package and a route look like on the wire. This chapter covers how those objects move between systems. Phase 2 of WIA ROB-010 defines a small REST surface: one token endpoint, three robot endpoints, two package endpoints, one route endpoint and two fleet endpoints. Phase 4 adds three charging-station endpoints. With these, a dispatcher, a merchant integration or an operations console can work with robots from any conformant vendor without writing vendor-specific adapters.

The Phase 2 document is short. It lists paths, sample requests and sample responses, but it leaves many decisions to the implementer: which scope protects which endpoint, how a client learns that an asynchronous command has finished, which error codes exist beyond the two it names, and how the machine-readable OpenAPI description relates to the paths in the text. This chapter goes through the normative surface in order and marks each point where the text is silent. For each one it gives a recommendation, labelled as a recommendation, that keeps an implementation interoperable with others that follow the same text.

Examples in this chapter reuse the sample objects from the rest of the book: robot 550e8400-e29b-41d4-a716-446655440000, package 7c9e6679-7425-40de-944b-e07fc1f90ae7 and route 3b241101-e2bb-4255-8caf-4136c566a962. The host api.example.com is a placeholder. Timestamps, durations and counts in example payloads are illustrative values, not benchmarks.

4.1 Design principles of the Phase 2 API

Phase 2 opens with five principles. Each one constrains the design in a specific way:

4.1.1 Resource map

The table below lists the complete normative surface together with the OAuth scope this book recommends for each endpoint. Phase 2 defines the five scopes but does not bind them to endpoints. The right-hand column is therefore a recommendation. It follows the scope descriptions in ยง2.2 of the spec as literally as possible.

MethodPathPurposeRecommended scopeSuccess code
POST/oauth/tokenObtain access token (client credentials)โ€” (client authentication)200
GET/api/v1/robots/{robot_id}Current RobotStaterobot:read200
POST/api/v1/robots/{robot_id}/commands/startBegin autonomous navigationrobot:control202
POST/api/v1/robots/{robot_id}/commands/emergency_stopStop immediatelyrobot:control200
POST/api/v1/packagesRegister a packagepackage:write201
GET/api/v1/packages/{package_id}Package status and tracking eventspackage:read200
POST/api/v1/routes/calculateCompute a route without dispatchingrobot:read (not specified)200
GET/api/v1/fleet/robotsList robots with filtersfleet:admin or robot:read (not specified)200
GET/api/v1/fleet/statisticsFleet-wide summaryfleet:admin (not specified)200
GET/api/v1/charging_stationsList stations (Phase 4 ยง4.1)fleet:admin (not specified)200
GET/api/v1/charging_stations/{station_id}/availabilityOpen slots at a stationfleet:admin (not specified)200
POST/api/v1/charging_stations/{station_id}/reserveReserve a slot for a robotfleet:admin (not specified)not specified (200 or 201)

"Not specified" means that Phase 2 gives no guidance. Whatever you choose, publish it in your OpenAPI document (Section 4.7) so that clients do not have to find out by trial and error.

4.1.2 What the API is not

Three limits are worth stating early, because integrators often assume otherwise:

  1. The API is not the safety channel. Stopping a robot that detects an obstacle 0.3 m away is an on-board function defined in Phase 3, with a motor-cutoff target under 100 ms. An HTTP round-trip through the internet cannot promise that. The remote emergency-stop endpoint adds an operator-initiated trigger. It does not replace the robot's local reflexes.
  2. The API is not the telemetry stream. GET /robots/{robot_id} returns a snapshot. Continuous state flows over MQTT on fleet/robots/{robot_id}/state (Phase 4, covered in Chapter 6). Polling the REST endpoint every second for a whole fleet uses up the rate limit and duplicates a channel that already exists.
  3. The API has no end-user identity. Only the client-credentials grant is defined, which means machine-to-machine access. A recipient who checks a delivery on a phone goes through the operator's own application. That application calls the ROB-010 API with its own credentials and applies its own per-user access checks.

4.2 Authentication and authorization

4.2.1 The client-credentials flow

Phase 2 uses the OAuth 2.0 client-credentials grant (RFC 6749 ยง4.4). The client authenticates itself with an identifier and a secret and receives a short-lived access token. It then sends that token as a Bearer credential (RFC 6750) on every API call. The spec's form-encoded request is shown below with placeholder credentials:

POST /oauth/token HTTP/1.1
Host: api.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=dispatch-svc&client_secret=<secret>&scope=robot:read%20robot:control%20package:write
{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "scope": "robot:read robot:control package:write"
}

Three details matter in practice. First, scopes are space-separated, encoded as %20 or + in the form body. Second, the scope field in the response states what was actually granted. An authorization server may grant less than was requested, so a client should compare the two and fail early rather than meet a 403 halfway through a delivery. Third, expires_in is in seconds. The 3600 in the spec is an example value, not a fixed lifetime. The client should read the value from every response and refresh ahead of time, for instance when 80โ€“90 % of the lifetime has passed, so that no request goes out with a token that is about to expire.

RFC 6749 also allows HTTP Basic client authentication instead of a secret in the body; a conformant server should accept at least the body form that Phase 2 shows. Higher-assurance deployments can follow the OAuth 2.0 Security Best Current Practice (RFC 9700) towards asymmetric client authentication and sender-constrained tokens (mutual TLS or DPoP), which are compatible additions.

4.2.2 Scopes and least privilege

The five scopes split the surface into read, control and administration:

ScopeSpec descriptionRisk if leaked
robot:readRead robot state and telemetryLocation tracking of robots (and, indirectly, of deliveries)
robot:controlSend control commandsRobots dispatched or halted by an attacker โ€” physical-world impact
package:readView package informationExposure of recipient names, phone numbers, addresses
package:writeCreate and manage packagesFraudulent jobs, manipulated drop-off points
fleet:adminFleet management and configurationWhole-fleet visibility and configuration changes

A typical deployment has several kinds of client. Each should hold only the scopes its job needs:

ClientNeeds toScopes to grantScopes to withhold
Public tracking dashboard backendShow where a package ispackage:readeverything else
Merchant integration (restaurant, shop)Submit orders, check their statuspackage:write, package:readrobot:control, fleet:admin
Dispatch serviceAssign robots and start navigationrobot:read, robot:control, package:readfleet:admin
Operations consoleMonitor the fleet, stop robots, manage chargingrobot:read, robot:control, fleet:adminpackage:write (ops rarely create orders)
Analytics jobRead fleet statisticsfleet:admin โ€” or a narrower custom read scoperobot:control

The analytics row points to a gap. fleet:admin mixes reading with configuring. If you need a read-only fleet scope, you may add one, for example fleet:read. Additive scopes do not break existing clients. Document the new scope and keep accepting fleet:admin as a superset.

Package scopes also need a tenant boundary, and scopes alone do not provide one. A merchant holding package:read must see only its own packages. In a multi-tenant deployment (Chapter 7 describes the profiles), the server derives the tenant from the client identity and filters every query by it. If a merchant asks for another merchant's package, return 404 rather than 403. A 403 tells the caller that the identifier exists.

4.2.3 401 versus 403

The two codes mean different things, and clients should react to them differently:

4.3 Robot control

4.3.1 Reading robot state

GET /api/v1/robots/{robot_id} returns the robot's current RobotState document, the Phase 1 schema from Chapter 3. The server may assemble it from the latest MQTT message. It must still validate against robot-state.json, with a required robot_id, timestamp, version, position and battery_level:

GET /api/v1/robots/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "robot_id": "550e8400-e29b-41d4-a716-446655440000",
    "timestamp": "2025-12-26T10:30:15Z",
    "version": "1.0.0",
    "position": { "latitude": 37.5665, "longitude": 126.9780,
                  "accuracy": 2.5, "source": "gps" },
    "heading": 45.0,
    "speed": 2.5,
    "battery_level": 87.0,
    "mode": "autonomous",
    "cargo": { "occupied_slots": 3, "total_slots": 5 }
}

Two things about this response are easy to get wrong. The timestamp is when the state was observed on the robot, not when the API answered, and a "last seen" display should use it. A robot offline for ten minutes still gets a 200 with its old timestamp โ€” not a fresh one, and not a 404. Second, the cargo object refers to CargoStatus, which Phase 1 references but never defines (Chapter 3.7). The occupied_slots/total_slots shape in the spec example is the only guidance available, so a sensible choice is to adopt it as your definition and document that.

4.3.2 Starting navigation: why 202

POST /api/v1/robots/550e8400-e29b-41d4-a716-446655440000/commands/start HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "destination": { "latitude": 37.5700, "longitude": 126.9850 },
    "route_preference": "fastest",
    "max_speed_ms": 2.5
}

HTTP/1.1 202 Accepted
Content-Type: application/json

{
    "command_id": "cmd-2025-12-26-001",
    "status": "queued",
    "estimated_arrival": "2025-12-26T10:45:00Z"
}

A 202 Accepted means "received and validated, not yet done". The cloud cannot complete a start command within one HTTP exchange. The robot might be on a cellular link with variable latency, it has to plan a route, check its own state (battery, cargo compartment closed, no active fault) and change its behaviour state. Holding the HTTP connection open for all of that would tie up server resources and cause client timeouts. So the server queues the command, returns an identifier and lets the client find out the outcome later.

The request body is not given its own schema, but each field maps onto Phase 1: destination is a Position (latitude and longitude at least), route_preference takes the Route enum (fastest, shortest, safest, energy_efficient), and max_speed_ms matches the navigation_constraints.max_speed_ms field. Validate the body against those Phase 1 definitions. A route_preference of "quickest" should get a 400, not be quietly treated as fastest.

How does the client learn the outcome? Phase 2 does not define a command-status endpoint. With only the normative surface, a client has two indirect signals:

Both are indirect. mode changes for many reasons, so a client cannot reliably link a given change to a given command. If you control the server, add an explicit resource, for example GET /api/v1/robots/{robot_id}/commands/{command_id} returning queued, accepted, rejected, executing, completed or failed with a reason, and return its URL in a Location header on the 202. This is the usual HTTP pattern for asynchronous work. It is an extension, so mark it as one in your OpenAPI document. Clients that know only Phase 2 will simply ignore it.

The example command_id is cmd-2025-12-26-001. Phase 1 puts no format on command IDs, so this is not a schema violation. A date plus a three-digit counter does, however, run out and collide across servers, so a UUID is a better choice.

4.3.3 Emergency stop: why 200

POST /api/v1/robots/550e8400-e29b-41d4-a716-446655440000/commands/emergency_stop HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "command_id": "cmd-2025-12-26-002",
    "status": "executed",
    "stopped_at": "2025-12-26T10:31:00Z"
}

Emergency stop is the one command that answers synchronously, and the reason is operational. The operator pressing the button needs to know whether the robot has stopped. A 200 with status: "executed" and a stopped_at timestamp is a confirmation from the robot. It is not just an acknowledgement from the server. To keep that meaning, the server should return 200 only after the robot has reported that the stop happened.

The hard part is what to do when the robot cannot be reached. Phase 2 does not say. The following behaviour is a recommendation:

Also note what the stop means for the robot. Under Phase 3 (Chapter 5), after an emergency stop the robot waits for manual clearance before it moves again. A later start command should therefore be rejected until an operator has cleared the stop, even if the caller holds robot:control.

4.3.4 Retries and idempotency

Networks fail after the server has acted but before the client receives the answer. If a start request times out, the client cannot tell whether the robot got the command. A blind retry might queue a second start with a slightly different destination. HTTP defines POST as not idempotent, and Phase 2 adds no mechanism for this. A recommended pattern:

4.4 Packages

4.4.1 Creating a package

POST /api/v1/packages HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "recipient": { "name": "Recipient Example", "phone": "+82-10-0000-0000",
                   "address": "Example-ro 1, Jung-gu, Seoul" },
    "pickup_location":  { "latitude": 37.5665, "longitude": 126.9780 },
    "dropoff_location": { "latitude": 37.5700, "longitude": 126.9850 },
    "package_details": { "weight_kg": 2.5, "category": "food",
                         "temperature_controlled": true },
    "access_control": { "access_code": "4821" },
    "priority": "express"
}

HTTP/1.1 201 Created
Location: /api/v1/packages/7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json

{
    "package_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "tracking_number": "WIA1234567890",
    "status": "pending",
    "estimated_delivery": "2025-12-26T11:00:00Z",
    "created_at": "2025-12-26T10:30:00Z"
}

This example differs from the spec text in two ways, both on purpose. The spec's 201 response returns "package_id": "pkg-2025-12-26-0001", while the Phase 1 Package schema declares package_id as format: uuid. A validator configured to check formats rejects that value (Chapter 3.7), so conformant servers should issue UUIDs, as here. The response also includes a Location header pointing to the new resource, which HTTP semantics (RFC 9110) recommend for 201 responses. It costs nothing and saves the client from building URLs itself.

The request body is a Package document without the server-assigned fields. The Phase 1 Package schema lists package_id as required, but at creation time the client does not have one. A practical approach is to validate the request against a derived "PackageCreate" schema: the same properties, with package_id, tracking_number and status removed from the required list and rejected if the client supplies them. Validate the stored document against the full Package schema.

Check server-side the Phase 1 constraints that come from the business domain: weight_kg must not be negative (the schema sets a minimum of 0; rejecting zero as well is a sensible local rule), category must be in the enum (food, retail, medical, document, other), priority must be normal, express or urgent, and access_code must match ^[0-9]{4,8}$. The server may add operational checks, such as a drop-off point outside the service area or a weight above what any robot in the zone can carry. Those should produce 400 responses with explicit codes (Section 4.6), not a package that stays pending forever.

Handling the access code. The access_code opens the cargo compartment, so it has to be treated as a secret throughout the stack. Accept it on creation. Never echo it in any response, including this 201 and later GETs. Keep it out of request logs, and pass it to the robot only over the authenticated command channel. The same goes for recipient contact data. The phone number and address are personal information under Korea's Personal Information Protection Act, under GDPR and under similar laws. Apply the usual rules: minimization, access control via package:read plus tenant filtering, and deletion when the retention period ends.

4.4.2 Reading status and tracking events

GET /api/v1/packages/7c9e6679-7425-40de-944b-e07fc1f90ae7 HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "package_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "status": "in_transit",
    "assigned_robot": "550e8400-e29b-41d4-a716-446655440000",
    "current_location": { "latitude": 37.5680, "longitude": 126.9810 },
    "estimated_delivery": "2025-12-26T10:55:00Z",
    "tracking_events": [
        { "timestamp": "2025-12-26T10:30:00Z", "event": "created" },
        { "timestamp": "2025-12-26T10:35:00Z", "event": "assigned_to_robot" },
        { "timestamp": "2025-12-26T10:40:00Z", "event": "picked_up" }
    ]
}

The response mixes two vocabularies. status uses the Phase 1 enum. The entries in tracking_events use their own event names, and the spec shows only three of them. Clients that turn events into user-facing messages need the full list, so a server should publish one. The table below keeps event names aligned with the status values the event produces. The first three names come from the spec; the rest are this book's proposal.

Tracking eventSourceResulting status
createdPhase 2 examplepending
assigned_to_robotPhase 2 exampleassigned
picked_upPhase 2 examplepicked_up
departedproposedin_transit
arrivedproposedarrived
deliveredproposeddelivered
delivery_failedproposedfailed
returned_to_senderproposedreturned

Two server rules keep the two vocabularies consistent. First, status must always equal the status produced by the most recent event. It should be derived, not stored separately, or the two will drift apart. Second, reject events that would make an illegal transition in the Phase 1 lifecycle, such as delivered straight from pending. Return 409 Conflict for these. Phase 2 does not list 409, but it is the standard HTTP code for a request that conflicts with the resource's current state. A few extra fields make each event more useful: the robot that produced it, its position, and a reason code on failure. Adding optional fields is a non-breaking change.

4.5 Routes, fleet and charging stations

4.5.1 Calculating a route

POST /api/v1/routes/calculate is a query that happens to use POST, because the input is a structured body. It computes a route but dispatches nothing, and no robot moves. That makes it safe to call from quoting screens ("how long would this delivery take?") and planning tools.

POST /api/v1/routes/calculate HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "start":       { "latitude": 37.5665, "longitude": 126.9780 },
    "destination": { "latitude": 37.5700, "longitude": 126.9850 },
    "route_preference": "fastest",
    "constraints": { "sidewalk_only": true, "avoid_roads": true }
}

HTTP/1.1 200 OK
Content-Type: application/json

{
    "route_id": "3b241101-e2bb-4255-8caf-4136c566a962",
    "total_distance_meters": 2300,
    "estimated_duration_seconds": 720,
    "battery_usage_percent": 8,
    "waypoints": [
        { "position": { "latitude": 37.5665, "longitude": 126.9780 }, "waypoint_type": "start" },
        { "position": { "latitude": 37.5680, "longitude": 126.9810 }, "waypoint_type": "intermediate" },
        { "position": { "latitude": 37.5700, "longitude": 126.9850 }, "waypoint_type": "destination" }
    ]
}

Compare this with the Phase 1 Route schema and three points stand out. First, the request calls the constraints object constraints, while the Route schema calls it navigation_constraints. The members are the same (max_speed_ms, avoid_roads, sidewalk_only, elevator_allowed). A tolerant server can accept both names in requests, but it should always emit navigation_constraints in stored Route documents. Second, the spec example returns "route_id": "route-2025-12-26-001", which breaks format: uuid in the same way the package ID did. The example above uses a UUID. Third, battery_usage_percent is not a Route property. Phase 1 does not forbid extra properties, so returning it is allowed, but a client validating with a strict "no additional properties" profile would reject it. Treat it as an estimate that the server adds. The 8 % in the example says nothing about real consumption.

The calculated route is not necessarily the one the robot will drive: its planners (Chapter 5) re-plan as conditions change, and start takes a destination, not a route_id. If you need "drive exactly this route", accept an optional route_id in the start body as an extension and define how long a calculated route stays valid.

4.5.2 Listing robots and pagination

GET /api/v1/fleet/robots?status=active&zone=gangnam&page=1&per_page=20 HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "total_count": 24,
    "page": 1,
    "per_page": 20,
    "robots": [
        {
            "robot_id": "550e8400-e29b-41d4-a716-446655440000",
            "status": "active",
            "battery_level": 87,
            "packages": 3,
            "location": { "latitude": 37.5665, "longitude": 126.9780 }
        }
    ]
}

The list entries are summaries, not RobotState documents, and they use their own field names (location rather than position, status rather than mode). The status filter value active is not in the Phase 1 mode enum, and Phase 2 does not list the allowed values. The fleet statistics endpoint implies a vocabulary of active, idle and charging. A reasonable mapping is: active for robots in autonomous or manual mode, idle for idle, charging for charging, and a separate error bucket, since an operator needs to find faulted robots quickly. Publish the mapping. zone is a free-form identifier whose meaning depends on the deployment.

Page-number pagination shifts when the set changes: a robot that switches from idle to active between two page requests can appear twice or not at all. Sort by a stable key (robot_id), treat total_count as approximate, and cap per_page on the server. For large fleets an opaque cursor parameter is the more robust extension.

4.5.3 Fleet statistics

GET /api/v1/fleet/statistics returns a summary with total_robots, per-status counts, deliveries_today, success_rate and average_delivery_time_minutes. The values in the spec example (such as a 98.7 success rate) are placeholders, not performance claims. For interoperability, the definitions matter more than the numbers. What counts as "today", and in which time zone? Is success_rate a percentage (0โ€“100) or a ratio (0โ€“1)? Is a returned package a failure? Phase 4 ยง6.1 defines delivery success rate as delivered divided by total deliveries. Use that formula, return it as a percentage to match the example, and document the time window. Chapter 7 uses these same statistics as operator KPIs.

4.5.4 Charging-station endpoints

Phase 4 ยง4.1 adds three paths under the same /api/v1 prefix: list stations, check availability at a station, and reserve a slot. The spec gives only the paths and a one-line description of each, with no bodies, response shapes or status codes. The protocol behind them (docking, AprilTag positioning, proactive charging at 20โ€“30 % battery) is covered in Chapter 6. For the API layer, the recommendations are these. Give reservations an identifier and an expiry. Return 409 when the slot is already taken. Make the reserve call idempotent in the same way as the start command, because a dispatcher that retries after a timeout must not end up holding two slots for one robot.

4.6 Errors and rate limits

4.6.1 The error envelope

Every non-2xx response carries the same structure:

{
    "error": {
        "code": "ROBOT_NOT_FOUND",
        "message": "Robot with ID 550e8400-e29b-41d4-a716-446655440000 not found",
        "details": { "robot_id": "550e8400-e29b-41d4-a716-446655440000" },
        "timestamp": "2025-12-26T10:30:00Z",
        "request_id": "req-2025-12-26-001"
    }
}

Each member has a different job. code is for programs: a stable, upper-case identifier that clients switch on. message is for people. It can change wording or language, and clients must never parse it. details carries structured context, such as the offending field or the ID that was not found. request_id links the client's error report to the server's logs. Put the same value in a response header (for example X-Request-Id) on every response, successful ones included, so that support can trace any call.

Only two codes appear in Phase 2: ROBOT_NOT_FOUND and RATE_LIMIT_EXCEEDED. Real implementations need more. The catalogue below is a proposal with names chosen to match the spec's style. Annex E of Phase 2 treats adding error codes as non-breaking, so clients must be written to handle codes they do not recognize. They should fall back to the HTTP status class.

HTTP statusMeaning in Phase 2Example code valuesClient should
200 OKSuccess (reads, route calculation, confirmed e-stop)โ€”Use the body
201 CreatedResource created (package)โ€”Store the new ID / Location
202 AcceptedQueued for asynchronous processing (start)โ€”Track the outcome separately
400 Bad RequestInvalid parameters or bodyVALIDATION_FAILED, INVALID_ROUTE_PREFERENCE, OUTSIDE_SERVICE_AREA (proposed)Fix the request; do not retry unchanged
401 UnauthorizedMissing or invalid tokenTOKEN_EXPIRED, INVALID_TOKEN (proposed)Refresh token once, retry once
403 ForbiddenValid token, not allowedINSUFFICIENT_SCOPE, ESTOP_NOT_CLEARED (proposed)Do not retry; report
404 Not FoundUnknown or not-visible resourceROBOT_NOT_FOUND (spec), PACKAGE_NOT_FOUND (proposed)Do not retry
429 Too Many RequestsRate limit exceededRATE_LIMIT_EXCEEDED (spec)Wait until retry_after, then retry
500 Internal Server ErrorServer-side failureINTERNAL_ERROR, ROBOT_UNREACHABLE (proposed)Retry idempotent calls with backoff; escalate e-stop failures

A server can add 409 Conflict (state conflicts, taken charging slots) and 503 Service Unavailable (planned maintenance) without breaking anything, because a client that falls back on the status class handles them correctly. For ESTOP_NOT_CLEARED, 403 fits better than 409: repeating the request will not help until a human intervenes.

4.6.2 Rate limiting

The default limit in the spec is 1,000 requests per hour per API key. Three headers describe the budget: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. When the budget runs out, the server returns 429 with a body like this:

{
    "error": {
        "code": "RATE_LIMIT_EXCEEDED",
        "message": "API rate limit exceeded. Retry after 2025-12-26T11:00:00Z",
        "retry_after": "2025-12-26T11:00:00Z"
    }
}

Several details are left open, and a careful implementation settles each one explicitly:

4.6.3 Client retry policy

A well-behaved client retries only when a retry can help, and spaces the retries out so that many clients do not all hit the server again at the same moment. The Python function below implements the policy from the table above: honour retry_after on 429, refresh the token once on 401, back off exponentially with random jitter on 500 and network errors (only for idempotent requests), and give up immediately on other 4xx codes.

import random, time
import requests
from datetime import datetime, timezone

RETRYABLE_STATUS = {429, 500, 503}

def call(session, method, url, token_source, *, json=None,
         idempotent=False, idem_key=None, max_attempts=5):
    refreshed = False
    for attempt in range(max_attempts):
        headers = {"Authorization": f"Bearer {token_source.get()}"}
        if idem_key:
            headers["Idempotency-Key"] = idem_key
        try:
            r = session.request(method, url, json=json, headers=headers, timeout=10)
        except requests.ConnectionError:
            if not (idempotent or idem_key):
                raise                      # outcome unknown: let caller decide
            time.sleep(backoff(attempt))
            continue

        if r.status_code < 400:
            return r.json() if r.content else None
        err = r.json().get("error", {}) if r.content else {}

        if r.status_code == 401 and not refreshed:
            token_source.refresh(); refreshed = True
            continue
        if r.status_code == 429:
            time.sleep(seconds_until(err.get("retry_after"), r.headers))
            continue
        if r.status_code in RETRYABLE_STATUS and (idempotent or idem_key):
            time.sleep(backoff(attempt))
            continue
        raise ApiError(r.status_code, err.get("code"), err.get("message"),
                       err.get("request_id"))
    raise ApiError(0, "RETRIES_EXHAUSTED", url, None)

def backoff(attempt, base=0.5, cap=30.0):
    return random.uniform(0, min(cap, base * 2 ** attempt))   # "full jitter"

def seconds_until(retry_after, headers):
    if retry_after:                                           # ISO 8601 in body
        t = datetime.fromisoformat(retry_after.replace("Z", "+00:00"))
        return max(0.0, (t - datetime.now(timezone.utc)).total_seconds())
    return float(headers.get("Retry-After", 1))

class ApiError(Exception):
    def __init__(self, status, code, message, request_id):
        super().__init__(f"{status} {code}: {message} (request_id={request_id})")
        self.status, self.code, self.request_id = status, code, request_id

A start command without an idempotency key is not retried after a connection error. Its outcome is unknown, and a duplicate dispatch is worse than a visible failure. The caller should read the robot state and decide. For the same reason the Idempotency-Key pattern from Section 4.3.4 is worth adopting: once it is in place, commands can be retried just as safely as reads.

4.7 Reconciling the OpenAPI stub with the Phase 2 text

The repository ships api/openapi.yaml. It is the only machine-readable interface artefact, and it does not match Phase 2:

openapi: 3.1.0
info: { title: WIA-ROBOT-005 Delivery Robot, version: 1.0.0 }
servers: [{ url: https://api.example.com/wia-robot-005/v1 }]
paths:
  /robot: { get: { responses: { '200': { description: OK } } } }
  /mission:
    post: { responses: { '201': { description: Created } } }
  /telemetry/{robotId}:
    post: { ... responses: { '202': { description: Accepted } } }
  /incident:
    post: { responses: { '201': { description: Created } } }
Aspectapi/openapi.yamlPhase 2 text
IdentifierWIA-ROBOT-005 (alias used by README and OpenAPI)WIA ROB-010
OpenAPI version3.1.0"OpenAPI 3.0 specification provided"
Base path/wia-robot-005/v1/api/v1
Paths/robot, /mission, /telemetry/{robotId}, /incidentrobots, commands, packages, routes, fleet (Section 4.1.1)
Schemasnone โ€” responses carry only descriptionsPhase 1 JSON Schemas with $ids
Securitynone declaredOAuth 2.0 client credentials with five scopes

WIA ROB-010 and WIA-ROBOT-005 name the same standard. This book uses ROB-010, the name carried by all four normative Phase documents. The stub's four paths come from the concepts in the README (mission manifest, telemetry framing, incident reporting). No Phase document defines them, and Chapter 8 treats them as extension candidates. For the Phase 2 surface, this book recommends writing a full OpenAPI document of your own, and choosing OpenAPI 3.1 even though the text says 3.0. OpenAPI 3.1 aligns its Schema Object with JSON Schema 2020-12, the dialect Phase 1 uses. The Phase 1 schemas can therefore be referenced by their $id as they are, without converting them to OpenAPI 3.0's restricted schema subset.

The fragment below shows the pattern for one read endpoint, one asynchronous command and the shared error envelope. It also declares the deployment's operational scope, which Annex E asks implementations to state in the OpenAPI document. x-wia-deployment-profile is an example name for a vendor extension; the spec does not prescribe one.

openapi: 3.1.0
info:
  title: WIA ROB-010 Delivery Robot API (Phase 2)
  version: 1.0.0
  x-wia-deployment-profile: multi-tenant
servers:
  - url: https://api.example.com/api/v1
components:
  securitySchemes:
    oauth:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://api.example.com/oauth/token
          scopes:
            robot:read: Read robot state and telemetry
            robot:control: Send control commands to robots
            package:read: View package information
            package:write: Create and manage packages
            fleet:admin: Fleet management and configuration
  schemas:
    RobotState:
      $ref: https://wiastandards.com/schemas/delivery-robot/v1/robot-state.json
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, pattern: "^[A-Z][A-Z0-9_]*$" }
            message: { type: string }
            details: { type: object }
            timestamp: { type: string, format: date-time }
            request_id: { type: string }
            retry_after: { type: string, format: date-time }
  responses:
    Error:
      description: Standard error envelope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
paths:
  /robots/{robot_id}:
    get:
      security: [{ oauth: [robot:read] }]
      parameters:
        - { name: robot_id, in: path, required: true,
            schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Current robot state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RobotState" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /robots/{robot_id}/commands/start:
    post:
      security: [{ oauth: [robot:control] }]
      parameters:
        - { name: robot_id, in: path, required: true,
            schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [destination]
              properties:
                destination:
                  $ref: https://wiastandards.com/schemas/delivery-robot/v1/robot-state.json#/$defs/Position
                route_preference:
                  enum: [fastest, shortest, safest, energy_efficient]
                max_speed_ms: { type: number, exclusiveMinimum: 0 }
      responses:
        "202": { description: Command queued }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }

Two notes on the fragment. The $ref into #/$defs/Position assumes that Position is defined under $defs in the RobotState schema, as it is in Phase 1. If your copy of the schemas is bundled differently, adjust the pointer. And because the server URL already ends in /api/v1, the paths are written relative to it. A document with servers: / and full paths is equally valid, provided you pick one form and stick to it. Chapter 7 treats this document as part of the evidence package, and an auditor will compare it with the running service.

4.8 Putting it together: a client walk-through

4.8.1 The sequence

The diagram shows the calls a dispatch service makes for one delivery. The asynchronous part sits between the 202 and the point where the robot's state changes.

Client                     API server                   Robot
  |  POST /oauth/token        |                            |
  |-------------------------->|                            |
  |  200 {access_token}       |                            |
  |<--------------------------|                            |
  |  POST /packages           |                            |
  |-------------------------->|                            |
  |  201 {package_id}         |                            |
  |<--------------------------|                            |
  |  POST /robots/{id}/commands/start                      |
  |-------------------------->|  command (queued)          |
  |  202 {command_id}         |--------------------------->|
  |<--------------------------|                            | plan, self-check
  |                           |  state: mode=autonomous    |
  |  GET /robots/{id}  (poll) |<---------------------------|
  |-------------------------->|                            |
  |  200 {mode, route, ...}   |                            |
  |<--------------------------|                            |
  |  GET /packages/{pkg} (poll, or subscribe via Phase 4)  |
  |-------------------------->|                            |
  |  200 {status: in_transit} |                            |
  |<--------------------------|                            |

4.8.2 With curl

API=https://api.example.com
ROBOT=550e8400-e29b-41d4-a716-446655440000

# 1. Token (client_id / client_secret from your provisioning system)
TOKEN=$(curl -s -X POST "$API/oauth/token" \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" \
  --data-urlencode "scope=robot:read robot:control package:write package:read" \
  | jq -r .access_token)

# 2. Register the package
PKG=$(curl -s -X POST "$API/api/v1/packages" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @package.json | jq -r .package_id)

# 3. Start navigation (idempotency key is a recommended extension)
curl -s -X POST "$API/api/v1/robots/$ROBOT/commands/start" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"destination":{"latitude":37.5700,"longitude":126.9850},
       "route_preference":"safest"}'

# 4. Poll until the robot reports autonomous mode (give up after ~60 s)
for i in $(seq 1 12); do
  MODE=$(curl -s "$API/api/v1/robots/$ROBOT" \
    -H "Authorization: Bearer $TOKEN" | jq -r .mode)
  echo "mode=$MODE"
  [ "$MODE" = "autonomous" ] && break
  [ "$MODE" = "error" ] && { echo "start failed"; exit 1; }
  sleep 5
done

# 5. Check the package
curl -s "$API/api/v1/packages/$PKG" -H "Authorization: Bearer $TOKEN" | jq .status

The polling loop is deliberately gentle, twelve requests in a minute. In production a dispatch service would learn of the change from the Phase 4 telemetry stream or a command-status resource instead.

4.8.3 A minimal TypeScript client

The README shows a TypeScript quick start with npm install @wia/delivery-robot and a RobotMissionClient class that calls createMission. This book treats that snippet as illustrative. It targets the README's mission concept, not the Phase 2 paths, and you should not assume a published package with that interface exists. The client below talks directly to the Phase 2 surface using only the standard fetch API. It caches the token, turns error envelopes into typed exceptions and exposes the endpoints from this chapter.

type Position = { latitude: number; longitude: number };
type RoutePreference = "fastest" | "shortest" | "safest" | "energy_efficient";

export class Rob010Error extends Error {
  constructor(public status: number, public code: string,
              public requestId?: string, public retryAfter?: string) {
    super(`${status} ${code}`);
  }
}

export class Rob010Client {
  private token?: { value: string; expiresAt: number };

  constructor(private base: string, private clientId: string,
              private clientSecret: string, private scopes: string[]) {}

  private async accessToken(): Promise<string> {
    const now = Date.now();
    if (this.token && now < this.token.expiresAt) return this.token.value;
    const body = new URLSearchParams({
      grant_type: "client_credentials",
      client_id: this.clientId,
      client_secret: this.clientSecret,
      scope: this.scopes.join(" "),
    });
    const r = await fetch(`${this.base}/oauth/token`, { method: "POST", body });
    if (!r.ok) throw new Rob010Error(r.status, "TOKEN_REQUEST_FAILED");
    const t = await r.json();
    // refresh at 85 % of the advertised lifetime
    this.token = { value: t.access_token, expiresAt: now + t.expires_in * 850 };
    return t.access_token;
  }

  private async req<T>(method: string, path: string, body?: unknown,
                        headers: Record<string, string> = {}): Promise<T> {
    const r = await fetch(`${this.base}/api/v1${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${await this.accessToken()}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
        ...headers,
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (r.status === 401) this.token = undefined;     // force refresh next call
    if (!r.ok) {
      const e = (await r.json().catch(() => ({}))).error ?? {};
      throw new Rob010Error(r.status, e.code ?? "UNKNOWN", e.request_id, e.retry_after);
    }
    return (r.status === 204 ? undefined : await r.json()) as T;
  }

  getRobot(robotId: string) {
    return this.req<{ mode: string; battery_level: number; timestamp: string }>(
      "GET", `/robots/${robotId}`);
  }

  startNavigation(robotId: string, destination: Position,
                  pref: RoutePreference = "safest", idemKey = crypto.randomUUID()) {
    return this.req<{ command_id: string; status: string }>(
      "POST", `/robots/${robotId}/commands/start`,
      { destination, route_preference: pref }, { "Idempotency-Key": idemKey });
  }

  emergencyStop(robotId: string) {
    return this.req<{ status: string; stopped_at: string }>(
      "POST", `/robots/${robotId}/commands/emergency_stop`);
  }

  createPackage(pkg: Record<string, unknown>) {
    return this.req<{ package_id: string; status: string }>("POST", "/packages", pkg);
  }

  getPackage(packageId: string) {
    return this.req<{ status: string; tracking_events: unknown[] }>(
      "GET", `/packages/${packageId}`);
  }
}

The client keeps policy in the caller's hands on purpose. It does not retry on its own. The caller can wrap methods with the retry policy from Section 4.6.3 and choose which calls count as safe to repeat. Note also that emergencyStop returns the robot's confirmation. The caller should treat any exception from it as "stop not confirmed" and alert a human, not simply log the error.

With this surface in place, the remaining question is what the robot does after it accepts a start command: how it plans, which rules of the pavement it follows, and how it reacts when something goes wrong. That is the Phase 3 navigation protocol, covered in Chapter 5.


โ† Chapter 3: Phase 1 Data Format โ€” RobotState, Package and Route Schemas | Back to Index | Chapter 5: Phase 3 Navigation Protocol โ€” Planning, Traffic Rules and Emergency Response โ†’