Files
mqtt-5-learning/teaching/lessons/0008-http-acceptance-vs-completion.html
Ryan fb30e8e884 Refactor MQTT course and bound backend message processing
Move lesson source into the teaching structure, align the course path with the learner's Go and DevOps experience, and add the interactive lesson guides. Add advanced DevOps lifecycle study as a separate section.

Bound MQTT message work and drain accepted jobs during shutdown; align the backend implementation with the revised course through Section 6.
2026-10-22 10:19:56 +03:30

511 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#fbfaf6">
<title>Lesson 8 — HTTP Acceptance Is Not Device Completion</title>
<link rel="stylesheet" href="../assets/lesson.css">
<script src="../assets/quiz.js" defer></script>
</head>
<body>
<main class="page">
<p class="eyebrow">Go MQTT Vending Backend · Lesson 8 · 6 minutes</p>
<h1>HTTP acceptance is not device completion</h1>
<p class="dek">Build an honest response for a vending command that travels from an HTTP caller through your Go service and Mosquitto to a device.</p>
<div class="meta"><span class="pill">🎯 One skill: name each acknowledgement</span><span class="pill">🧠 Recall before reveal</span><span class="pill">🧪 Disposable broker lab</span></div>
<section class="card callout">
<p class="question">❓ Start with the system story</p>
<p>A dashboard user asks the backend to ping a vending machine. Your API admitted a command record to process-local memory and published the MQTT request. The broker returned PUBACK, but the machine has not replied yet. What can you tell the caller right now?</p>
<p><strong>Pause and answer aloud before reading on.</strong></p>
</section>
<section class="card"><h2>📍 Checkout status</h2><p><code>internal/httpapi/handlers.go</code> and <code>server.go</code> are Section 8 files to create; they do not yet exist in the current codebase. The complete target files and edited backend entrypoint are included below. They depend on the command service introduced in Section 7.</p><p><strong>Lab boundary:</strong> the API binds to loopback and has no authentication. Do not expose it to another network; Section 11 adds API authentication and per-device authorization. HTTP 202 admits work into RAM only. A process restart loses command and retry-key state until Sections 13–14 add durable storage and a recoverable outbox.</p></section>
<h2>🗺️ First, see the whole path</h2>
<p>The API request, MQTT exchange, and device operation are separate stages. Each can succeed or fail independently.</p>
<div class="flow" aria-label="HTTP-to-device command flow">
<div class="step">Caller sends<br><code>POST</code></div><span class="arrow">→</span>
<div class="step">Backend records command<br><strong>API admission</strong></div><span class="arrow">→</span>
<div class="step">Broker replies<br><strong>PUBACK</strong></div><span class="arrow">→</span>
<div class="step">Device reports later<br><strong>application result</strong></div>
</div>
<p>HTTP 202 means the server accepted the request for processing; the processing is not complete. A QoS 1 PUBACK is evidence for its MQTT publication exchange. The device's later result is application evidence. The API should return a command ID now and let the caller query status later. <a href="../reference/http-mqtt-evidence.html">Keep the evidence map nearby ↗</a></p>
<h2>🔎 Then, read the function through its purpose</h2>
<div class="two-col">
<article class="card">
<h3>📄 What the file does</h3>
<p>The new <code>internal/httpapi/handlers.go</code> will translate HTTP input into command-service calls and command records into HTTP responses. It will not publish MQTT packets itself.</p>
</article>
<article class="card">
<h3>🚀 `createCommand` — short summary</h3>
<p>Validates the request and idempotency key, asks the command service to admit one command, then returns its ID and current evidence.</p>
</article>
</div>
<article class="card">
<h3>🧩 Details that affect behavior</h3>
<ul>
<li><code>POST /devices/{deviceID}/commands</code> identifies the operation and device. The handler validates the path ID and command before creating work.</li>
<li><code>Idempotency-Key</code> lets a caller retry after a lost HTTP response without intentionally creating a second command. The key is not an MQTT packet ID or the command ID.</li>
<li>The command service owns the command record and MQTT publish. Keeping MQTT out of the handler makes the HTTP boundary easier to inspect and replace.</li>
<li>HTTP 202 can include the command ID and a <code>Location</code> pointing to <code>GET /commands/{id}</code>. That status resource reports the latest evidence known by the backend.</li>
<li>In this lab, retry keys and command records are in memory. A process restart loses them; later persistence lessons add a durable boundary.</li>
</ul>
</article>
<article class="card">
<h3>📦 `writeCommand` — shape the status response</h3>
<p><strong>Summary:</strong> Sets response metadata, writes the chosen HTTP status, then encodes a command record as JSON.</p>
<pre><code>type commandResponse struct {
CommandID string `json:"command_id"`
DeviceID string `json:"device_id"`
Status string `json:"status"`
BrokerAcked bool `json:"broker_acked"`
}
func writeCommand(w http.ResponseWriter, record commands.Record, status int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", "/commands/"+record.CommandID)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(commandResponse{
CommandID: record.CommandID,
DeviceID: record.DeviceID,
Status: string(record.Status),
BrokerAcked: record.BrokerAcked,
})
}
</code></pre>
<p><strong>Details:</strong> <code>Location</code> tells the client where to read status. The HTTP status is an argument because the same mapper serves both POST admission and GET status. <code>record.Status</code> and <code>BrokerAcked</code> remain separate fields: the first is application progress, while the second records MQTT publish acknowledgement. The caller should query the returned resource for later device evidence.</p>
<p class="small">This function excerpt focuses on response construction. The full handler file, including imports, request handling, and supporting functions, is included below.</p>
</article>
<p class="small">The route patterns shown in this course use Go's method-aware <code>ServeMux</code> syntax and <code>Request.PathValue</code>, available from Go 1.22. This repository's <code>go.mod</code> targets Go 1.27.1. <a href="https://go.dev/blog/routing-enhancements">Go team: routing enhancements ↗</a></p>
<h2>🗺️ File and function map</h2>
<p><strong>File overview:</strong> <code>handlers.go</code> owns HTTP routes, request validation, retry-key handling, and JSON responses. <code>server.go</code> builds a loopback-only server with I/O deadlines. The backend <code>main.go</code> wires HTTP to the command service while continuing to receive MQTT results and expire old commands.</p>
<div class="two-col">
<article class="card">
<h3>📄 <code>internal/httpapi/handlers.go</code></h3>
<p><strong>Function summaries:</strong> <code>NewAPI</code> stores dependencies; <code>Handler</code> registers the two routes; <code>createCommand</code> validates and admits a request; <code>getCommand</code> reads current status; <code>decodeRequest</code> bounds and strictly decodes JSON; <code>writeCommand</code> shapes the JSON response; <code>validPathID</code> checks safe path IDs; <code>newCommandID</code> generates a random stable ID.</p>
<h4>🔎 Details</h4>
<ul>
<li><code>createCommand</code> validates device, idempotency key, body size/shape, and allowed command before calling the service. It hashes device plus command as a request fingerprint. Reusing the same key and same input returns the saved record; reusing it with different input returns conflict.</li>
<li>The retry map is protected by a mutex, and this simple implementation keeps the lock during service admission. That makes same-key retries atomic in one process, but serializes concurrent creates and loses entries on restart.</li>
<li>If the service returns a record plus a publish error, the handler still stores that record and returns <code>202</code>: the command exists, but its publish outcome may be uncertain. The status body keeps <code>status</code> and <code>broker_acked</code> separate.</li>
<li><code>decodeRequest</code> caps the body, rejects unknown JSON fields, and requires exactly one JSON value. <code>writeCommand</code> sets <code>Location</code> before writing the status and body; after <code>WriteHeader</code>, headers cannot be changed.</li>
</ul>
</article>
<article class="card">
<h3>🌐 <code>internal/httpapi/server.go</code></h3>
<p><strong><code>NewServer</code> summary:</strong> accepts only a loopback address with an explicit port and attaches the API handler plus finite read/write/idle timeouts.</p>
<h4>🔎 Details</h4><p>Loopback keeps this teaching endpoint local; it is not an internet-facing production server. Header, body, response, and idle timeouts limit resource use from slow clients. Production exposure would need an intentional network binding, authentication/authorization, TLS termination, and operational configuration.</p>
</article>
<article class="card">
<h3>🔌 <code>cmd/backend/main.go</code></h3>
<p><strong><code>run</code> summary:</strong> starts MQTT, wires store → command service → HTTP API, runs HTTP in a goroutine, processes MQTT results and expiry ticks, then shuts down HTTP on cancellation.</p>
<p><strong><code>main</code> summary:</strong> reports a startup/runtime error and exits nonzero.</p>
<h4>🔎 Details</h4><p>MQTT starts before the API is exposed, so a request cannot be accepted when the command publisher has no initialized connection manager. The main select loop continues processing response messages while HTTP serves requests; otherwise commands could never advance to device-reported results. The ticker changes pending/accepted commands to unknown after deadlines. On a signal, <code>Server.Shutdown</code> stops accepting new HTTP requests and waits up to five seconds for in-flight handlers. The in-memory store still loses all status on process restart.</p>
</article>
</div>
<h2>🧪 Check your model</h2>
<p>Choose the evidence each event actually provides. Options in each question have the same word count; the correct answer moves between positions.</p>
<form class="card quiz" data-quiz data-correct="a" data-explanation="Correct. HTTP 202 says this API accepted the request for processing; it does not say MQTT or device work completed." data-retry="Think about which component sends the HTTP response and which component has not replied yet.">
<fieldset class="question-block">
<legend><strong>1. What does HTTP 202 support?</strong></legend>
<label class="option"><input type="radio" name="q1" value="a"> The API accepted work for processing</label>
<label class="option"><input type="radio" name="q1" value="b"> The broker acknowledged this MQTT publication</label>
<label class="option"><input type="radio" name="q1" value="c"> Device completed its requested vending operation</label>
</fieldset>
<button type="submit">Check answer</button>
<p class="feedback" data-feedback aria-live="polite"></p>
</form>
<form class="card quiz" data-quiz data-correct="b" data-explanation="Correct. PUBACK is the QoS 1 acknowledgement for this MQTT hop. It does not prove device delivery or business completion." data-retry="Trace the MQTT sender and receiver for this publication. Which component sends PUBACK?">
<fieldset class="question-block">
<legend><strong>2. What does a successful QoS 1 PUBACK establish?</strong></legend>
<label class="option"><input type="radio" name="q2" value="a"> Device completed the vending operation after publication</label>
<label class="option"><input type="radio" name="q2" value="b"> The broker acknowledged this MQTT client publication</label>
<label class="option"><input type="radio" name="q2" value="c"> HTTP caller received a command status resource</label>
</fieldset>
<button type="submit">Check answer</button>
<p class="feedback" data-feedback aria-live="polite"></p>
</form>
<form class="card quiz" data-quiz data-correct="c" data-explanation="Correct. The GET endpoint exposes the latest application evidence the backend has recorded; it does not query or guarantee a live device connection." data-retry="A status endpoint reads backend state. Does it necessarily contact the device now?">
<fieldset class="question-block">
<legend><strong>3. What can GET /commands/{id} return later?</strong></legend>
<label class="option"><input type="radio" name="q3" value="a"> The device's current network connection status only</label>
<label class="option"><input type="radio" name="q3" value="b"> The broker's acknowledgement of the HTTP request</label>
<label class="option"><input type="radio" name="q3" value="c"> The latest application evidence backend has stored</label>
</fieldset>
<button type="submit">Check answer</button>
<p class="feedback" data-feedback aria-live="polite"></p>
</form>
<h2>🛠️ Your tiny design task</h2>
<div class="card">
<p>Open the Section 8 implementation and find <code>writeCommand</code>. Without changing code yet, write the response a caller should see immediately after command admission:</p>
<ol>
<li>Which HTTP status should it return?</li>
<li>Which stable identifier should the caller save?</li>
<li>What URL should the <code>Location</code> header name?</li>
<li>Which status field must not be described as “completed” just because PUBACK arrived?</li>
</ol>
<p>Then compare your answer to the existing implementation in <a href="../course-source/08-http-commands.md">Section 8 — Expose device commands through HTTP ↗</a>. If it matches, explain why in your own words. If it does not, identify which component owns the evidence.</p>
</div>
<details class="card" open><summary><strong>📦 Section 8 complete HTTP files</strong></summary><p>Complete source files for this lesson are included here. The code blocks earlier in the lesson focus on the behavior being taught; use these full files when implementing it.</p><article class="card"><h3>📄 internal/httpapi/handlers.go</h3><pre><code>package httpapi
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"example.com/mqtt5-learning/internal/commands"
)
const maxRequestBytes = 4 * 1024
// The API depends on command operations, not on the MQTT client itself.
type commandService interface {
CreateAndPublish(ctx context.Context, deviceID, commandID string) (commands.Record, error)
Get(commandID string) (commands.Record, bool)
}
type retryEntry struct {
fingerprint [32]byte
record commands.Record
}
type API struct {
commands commandService
serviceCtx context.Context
mu sync.Mutex
retries map[string]retryEntry
}
// NewAPI keeps retries in memory and ties accepted work to the app lifecycle.
func NewAPI(serviceCtx context.Context, service commandService) *API {
return &amp;API{commands: service, serviceCtx: serviceCtx, retries: make(map[string]retryEntry)}
}
type createRequest struct {
Command string `json:"command"`
}
type commandResponse struct {
CommandID string `json:"command_id"`
DeviceID string `json:"device_id"`
Status string `json:"status"`
BrokerAcked bool `json:"broker_acked"`
Detail string `json:"detail,omitempty"`
}
func (a *API) Handler() http.Handler {
// Keep the public surface small: create commands and read their status.
mux := http.NewServeMux()
mux.HandleFunc("POST /devices/{deviceID}/commands", a.createCommand)
mux.HandleFunc("GET /commands/{commandID}", a.getCommand)
return mux
}
func (a *API) createCommand(w http.ResponseWriter, r *http.Request) {
// Validate caller input before reserving an idempotency key or publishing.
deviceID := r.PathValue("deviceID")
if !validPathID(deviceID) {
http.Error(w, "invalid device ID", http.StatusBadRequest)
return
}
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if key == "" || len(key) &gt; 128 {
http.Error(w, "valid Idempotency-Key required", http.StatusBadRequest)
return
}
var input createRequest
if err := decodeRequest(w, r, &amp;input); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &amp;tooLarge) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if input.Command != "ping" {
http.Error(w, "unsupported command", http.StatusBadRequest)
return
}
// The fingerprint detects reuse of one key for different request content.
fingerprint := sha256.Sum256([]byte(deviceID + "\x00" + input.Command))
// This in-memory lesson serializes admissions to make same-key retries atomic.
a.mu.Lock()
defer a.mu.Unlock()
if previous, ok := a.retries[key]; ok {
if previous.fingerprint != fingerprint {
http.Error(w, "idempotency key reused with different request", http.StatusConflict)
return
}
writeCommand(w, previous.record, http.StatusAccepted)
return
}
commandID, err := newCommandID()
if err != nil {
http.Error(w, "could not create command ID", http.StatusInternalServerError)
return
}
record, publishErr := a.commands.CreateAndPublish(a.serviceCtx, deviceID, commandID)
if record.CommandID == "" {
if publishErr != nil {
http.Error(w, "command admission unavailable", http.StatusServiceUnavailable)
return
}
http.Error(w, "command admission failed", http.StatusServiceUnavailable)
return
}
a.retries[key] = retryEntry{fingerprint: fingerprint, record: record}
// The command record exists even if the publish outcome is uncertain.
writeCommand(w, record, http.StatusAccepted)
}
func (a *API) getCommand(w http.ResponseWriter, r *http.Request) {
record, ok := a.commands.Get(r.PathValue("commandID"))
if !ok {
http.Error(w, "command not found", http.StatusNotFound)
return
}
writeCommand(w, record, http.StatusOK)
}
// decodeRequest enforces a byte limit, strict fields, and exactly one JSON value.
func decodeRequest(w http.ResponseWriter, r *http.Request, target any) error {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("invalid JSON request: %w", err)
}
var trailing any
if err := decoder.Decode(&amp;trailing); err != io.EOF {
if err == nil {
return errors.New("request must contain one JSON value")
}
return fmt.Errorf("trailing request data: %w", err)
}
return nil
}
func writeCommand(w http.ResponseWriter, record commands.Record, status int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", "/commands/"+record.CommandID)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(commandResponse{CommandID: record.CommandID, DeviceID: record.DeviceID, Status: string(record.Status), BrokerAcked: record.BrokerAcked, Detail: record.Detail})
}
// Path IDs use the same restricted alphabet as contract and topic IDs.
func validPathID(value string) bool {
if value == "" || len(value) &gt; 128 {
return false
}
for _, r := range value {
if !(r &gt;= 'a' &amp;&amp; r &lt;= 'z' || r &gt;= 'A' &amp;&amp; r &lt;= 'Z' || r &gt;= '0' &amp;&amp; r &lt;= '9' || strings.ContainsRune("._-", r)) {
return false
}
}
return true
}
func newCommandID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return "cmd-" + hex.EncodeToString(raw[:]), nil
}
</code></pre></article>
<article class="card"><h3>📄 internal/httpapi/server.go</h3><pre><code>package httpapi
import (
"fmt"
"net"
"net/http"
"time"
)
func NewServer(address string, api *API) (*http.Server, error) {
// The teaching API listens only on loopback and uses finite I/O timeouts.
host, port, err := net.SplitHostPort(address)
if err != nil || (host != "127.0.0.1" &amp;&amp; host != "localhost") || port == "" {
return nil, fmt.Errorf("HTTP lab address must be loopback with an explicit port")
}
return &amp;http.Server{
Addr: address, Handler: api.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}, nil
}
</code></pre></article>
<article class="card"><h3>📄 cmd/backend/main.go</h3><pre><code>package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/mqtt5-learning/internal/commands"
"example.com/mqtt5-learning/internal/config"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/httpapi"
"example.com/mqtt5-learning/internal/mqtt"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
cfg, err := config.Load(os.Args[1:])
if err != nil {
return fmt.Errorf("load config: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
inbox := make(chan mqtt.Message, 16)
telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
if err != nil { return err }
responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
if err != nil { return err }
salesFilter, err := contracts.DeviceFilter(contracts.KindSales)
if err != nil { return err }
topics := []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter}
manager, err := mqtt.Start(ctx, cfg, topics, inbox)
if err != nil {
return fmt.Errorf("start MQTT: %w", err)
}
defer mqtt.Stop(manager)
// Wire the command service before exposing its HTTP endpoints.
store := commands.NewStore()
publisher := mqtt.CommandPublisher{Manager: manager}
service := commands.NewService(store, publisher, 15*time.Second)
api := httpapi.NewAPI(ctx, service)
server, err := httpapi.NewServer("127.0.0.1:18080", api)
if err != nil {
return err
}
serverErrors := make(chan error, 1)
go func() { serverErrors &lt;- server.ListenAndServe() }()
log.Printf("HTTP API listening on %s", server.Addr)
expirationTicker := time.NewTicker(time.Second)
defer expirationTicker.Stop()
// Keep response processing and expiration active for the server lifetime.
for {
select {
case &lt;-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdown); err != nil {
return fmt.Errorf("HTTP shutdown: %w", err)
}
return nil
case err := &lt;-serverErrors:
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("HTTP server: %w", err)
}
return nil
case &lt;-expirationTicker.C:
service.Expire(time.Now().UTC())
case message := &lt;-inbox:
topic, err := contracts.ParseTopic(message.Topic)
if err != nil {
log.Printf("rejected topic=%q reason=%v", message.Topic, err)
continue
}
switch topic.Kind {
case contracts.KindResponses:
if err := commands.HandleMessage(service, message); err != nil {
log.Printf("rejected response topic=%q reason=%v", message.Topic, err)
}
case contracts.KindTelemetry:
var heartbeat contracts.Heartbeat
heartbeat, err = contracts.Decode[contracts.Heartbeat](message.Payload, topic, time.Now().UTC())
if err != nil {
log.Printf("rejected telemetry topic=%q reason=%v", message.Topic, err)
continue
}
log.Printf("valid heartbeat device=%s message=%s", heartbeat.DeviceID, heartbeat.MessageID)
case contracts.KindSales:
var report contracts.SalesReport
report, err = contracts.Decode[contracts.SalesReport](message.Payload, topic, time.Now().UTC())
if err != nil {
log.Printf("rejected sales topic=%q reason=%v", message.Topic, err)
continue
}
log.Printf("valid sales report device=%s report=%s", report.DeviceID, report.ReportID)
default:
log.Printf("ignored topic kind=%s", topic.Kind)
}
}
}
}
</code></pre></article></details>
<h2>🏗️ Build and run the HTTP-to-device path</h2>
<p>After saving the Section 7 command files plus the Section 8 HTTP files and backend wiring, build and start the lab from the repository root:</p>
<pre><code>go test ./...
go build -o /tmp/mqtt5-lab-backend ./cmd/backend
go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
docker compose -p mqtt5-learning up -d broker
docker compose -p mqtt5-learning ps</code></pre>
<p>The tests cover only the tests present in the checkout; the builds verify the complete API and simulator programs compile. Run the simulator and backend in separate terminals:</p>
<pre><code>/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001</code></pre>
<pre><code>/tmp/mqtt5-lab-backend -username backend -client-id lab-backend</code></pre>
<p>Once the backend reports that the HTTP API is listening, submit a harmless ping. Copy the returned <code>command_id</code> into the GET request:</p>
<pre><code>curl -i -X POST http://127.0.0.1:18080/devices/device-001/commands \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: lesson8-ping-001' \
--data '{"command":"ping"}'
# Replace cmd-example with the command_id returned by POST.
curl -i http://127.0.0.1:18080/commands/cmd-example</code></pre>
<p>Expect HTTP <code>202</code> from POST, then later application status from GET after the simulator responds. Repeat the same POST with the same idempotency key and body; it should return the same command ID. PUBACK alone is not device completion. Stop both clients with Ctrl+C, then stop the broker with <code>docker compose -p mqtt5-learning stop broker</code>. Ask me a follow-up if the observed status or retry result differs from your prediction.</p>
<section class="card callout">
<h2>✅ Takeaway</h2>
<p><strong>202 = API admission. PUBACK = MQTT publication acknowledgement. Device result = application-reported outcome.</strong></p>
<p>Keep those facts separate in logs, API fields, dashboards, and incident reports. Ask me a follow-up question if any boundary still feels fuzzy.</p>
</section>
<footer>
<p>↩️ Previous: <a href="../course-source/07-command-response.md">Section 7 — command/response</a> · 📚 Reference: <a href="../reference/http-mqtt-evidence.html">HTTP and MQTT evidence</a></p>
<p>Previous: <a href="0007-command-state.html">Lesson 7 — command identity and state</a> · Next: <a href="0009-session-recovery.html">Lesson 9 — session recovery</a> · Full implementation: <a href="../course-source/08-http-commands.md">Section 8 source lesson</a>.</p>
<p>Primary sources: <a href="https://datatracker.ietf.org/doc/rfc9110/">RFC 9110 §15.3.3 — 202 Accepted</a>; <a href="https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html">OASIS MQTT 5.0 Standard — PUBACK</a>; <a href="https://pkg.go.dev/net/http">Go `net/http` documentation</a>. See <a href="../RESOURCES.md">all workspace resources</a>.</p>
</footer>
</main>
</body>
</html>