feat: expand MQTT contract and reliability lessons
This commit is contained in:
+272
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
## 🎬 Start with the system story
|
||||
|
||||
> 💡 **Story question:** A user clicks “ping device” in an HTTP API. How can the API respond promptly while the MQTT command and device response happen asynchronously?
|
||||
> **❓ Question:** **A user clicks “ping device” in an HTTP API. How can the API respond promptly while the MQTT command and device response happen asynchronously?**
|
||||
|
||||
The HTTP handler validates the request and asks an application service to create a command. The service records its identity and pending status, publishes through the MQTT client, then exposes later device evidence through a status endpoint. HTTP request lifetime, MQTT delivery, and the device operation each have their own acknowledgement and failure boundary.
|
||||
|
||||
@@ -33,7 +33,7 @@ sequenceDiagram
|
||||
|
||||
Prerequisite: Section 7 provides a command service and queryable in-memory states. The backend and simulator continue to read their shared disposable password from `configs/lab.toml`; select each broker username with `-username` when starting the process.
|
||||
|
||||
Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Create files at the project-relative paths shown below, not under `lessons/`. Continue the same application and pinned Paho v0.23.0 dependency. These are guided implementation exercises; attempt each step before requesting a full solution.
|
||||
Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete HTTP implementation blocks at the project-relative paths shown below. This lesson includes the handlers and server setup; the remaining exercises trace request, retry, and device-result behavior.
|
||||
|
||||
## 🌐 Translate evidence between HTTP and MQTT
|
||||
|
||||
@@ -45,7 +45,9 @@ POST + retry key → admit command C → HTTP 202 + C
|
||||
GET C --------------------------------------------------------------→ status C
|
||||
```
|
||||
|
||||
Do not convert PUBACK directly into HTTP "device completed". Also do not detach work using an unbounded `context.Background()` per command: accepted work needs the service lifecycle, its own deadline, and tracked shutdown. The HTTP request context controls parsing and admission, while the service controls work after acceptance.
|
||||
**❓ Question:** **The backend publishes a ping and receives PUBACK. Does that mean the device accepted or completed it?**
|
||||
|
||||
**💬 Answer:** No. PUBACK confirms the broker acknowledged the backend's publication. It does not confirm device delivery or business acceptance. The HTTP API returns a command ID, and a later status request reports the device's application result if one arrives. The handler uses the request context while parsing. After validation, it passes work to the command service with the application lifecycle context and the command's own deadline. A disconnected caller therefore does not cancel accepted work.
|
||||
|
||||
Predict a lost HTTP response: the server admits C, but the caller never sees 202 and retries. The same retry key and payload should return C, not create D. Reusing the key with a changed payload is a conflict. Later, scope keys by authenticated caller so two users cannot accidentally share a command record.
|
||||
|
||||
@@ -58,37 +60,295 @@ No. The lesson's in-memory command and retry-key maps disappear. HTTP 202 descri
|
||||
|
||||
## ⚙️ 1. Add HTTP configuration and handlers
|
||||
|
||||
> 💡 **Story question:** Which process accepts the user's HTTP request, and how does that process reach the command service without putting MQTT logic in the handler?
|
||||
> **❓ Question:** **Which process accepts the user's HTTP request, and how does that process reach the command service without putting MQTT logic in the handler?**
|
||||
|
||||
Wire the HTTP server to the application service and preserve bounded parsing, deadlines, and controlled shutdown.
|
||||
|
||||
Create `internal/httpapi/server.go`, `handlers.go`, and `handlers_test.go`. Extend `internal/config/config.go` and `configs/lab.toml` with a loopback listener such as `127.0.0.1:18080`, request-body limit, and server timeout settings. Preserve defaults < TOML < environment < explicit CLI flags for supported settings; keep `mqtt.password` TOML-only. Validate these settings before starting listeners.
|
||||
Save the complete API implementation below as `internal/httpapi/handlers.go` and `server.go`. It binds only to loopback, limits and strictly decodes request bodies, requires an idempotency key, and sets finite HTTP timeouts. The lab listener address is fixed in the complete backend entrypoint below; production authorization is introduced later.
|
||||
|
||||
Compose HTTP and MQTT in the existing `cmd/backend/main.go`. Keep the simulator separate. Section 15 introduces separate API and ingestion processes.
|
||||
`internal/httpapi/handlers.go`:
|
||||
|
||||
```go
|
||||
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 &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) > 128 { http.Error(w, "valid Idempotency-Key required", http.StatusBadRequest); return }
|
||||
var input createRequest
|
||||
if err := decodeRequest(w, r, &input); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &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(&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) > 128 { return false }
|
||||
for _, r := range value {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '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
|
||||
}
|
||||
```
|
||||
|
||||
`internal/httpapi/server.go`:
|
||||
|
||||
```go
|
||||
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" && host != "localhost") || port == "" {
|
||||
return nil, fmt.Errorf("HTTP lab address must be loopback with an explicit port")
|
||||
}
|
||||
return &http.Server{
|
||||
Addr: address, Handler: api.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
The complete final `cmd/backend/main.go` is below. It starts MQTT before HTTP, routes response messages into the command service, and shuts down the HTTP server before disconnecting MQTT. It replaces the Section 5 backend entrypoint.
|
||||
|
||||
```go
|
||||
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)
|
||||
topics := []string{"lab/v1/devices/+/telemetry", "lab/v1/devices/+/responses", "lab/v1/devices/+/sales"}
|
||||
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 <- 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 <-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 := <-serverErrors:
|
||||
if !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("HTTP server: %w", err) }
|
||||
return nil
|
||||
case <-expirationTicker.C:
|
||||
service.Expire(time.Now().UTC())
|
||||
case message := <-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📋 2. Define the API contract
|
||||
|
||||
> 💡 **Story question:** What should the caller receive immediately when device work will finish later?
|
||||
> **❓ Question:** **What should the caller receive immediately when device work will finish later?**
|
||||
|
||||
Return an accepted command ID, then provide a status resource that reports later evidence.
|
||||
|
||||
Implement `POST /devices/{id}/commands` for a JSON command such as `{"command":"ping"}` and `GET /commands/{id}` for status. Limit bodies, reject trailing JSON and unknown fields, validate device IDs, and return JSON errors consistently. Keep the listener local; this lesson does not implement production user authorization.
|
||||
The complete handlers above implement `POST /devices/{id}/commands` and `GET /commands/{id}`. They limit bodies, reject trailing JSON and unknown fields, validate the path ID, require an idempotency key, and return consistent JSON status. The listener remains local; production user authorization is introduced later.
|
||||
|
||||
Return HTTP 202 with a command ID and a Location pointing to its status after service admission. Document malformed-input 400, oversized-body 413, unknown-command/device 404 or a consistent chosen alternative, conflict 409, and admission-unavailable 503 behavior. Transport uncertainty after acceptance belongs in command status rather than silently discarding an accepted command.
|
||||
|
||||
## 🌐 3. Implement HTTP retry identity
|
||||
|
||||
> 💡 **Story question:** If the server accepted a request but the HTTP response was lost, how can the caller retry without creating a second device action?
|
||||
> **❓ Question:** **If the server accepted a request but the HTTP response was lost, how can the caller retry without creating a second device action?**
|
||||
|
||||
Use an HTTP idempotency key with stable request semantics; keep it distinct from the command ID and MQTT packet identifier.
|
||||
|
||||
Require an `Idempotency-Key` header for POST. Map the key and request fingerprint to the existing command ID. An identical retry returns the same ID; changed content under the same key returns 409. Serialize concurrent admission for the same key. Bound key retention and document that process restart loses this in-memory guarantee. This key, the stable device command ID, and MQTT packet IDs serve separate purposes.
|
||||
The handler keeps the key, request fingerprint, and command record together in memory. An identical retry returns the original ID; changed content under that key returns 409. The mutex makes admission atomic in this single-process lesson. A restart loses this map, so this is not a durable idempotency guarantee. The HTTP key, command ID, and MQTT packet ID have different lifetimes and purposes.
|
||||
|
||||
Use the HTTP context for request parsing and admission. Once accepted, execute under the application's lifecycle and command deadline, so client disconnect does not cancel the command. Inject the command service through a small interface and keep MQTT calls out of handlers.
|
||||
|
||||
## 🧪 4. Run and verify
|
||||
|
||||
> 💡 **Story question:** What evidence demonstrates each boundary in the round trip—from HTTP acceptance to broker delivery to device result?
|
||||
> **❓ Question:** **What evidence demonstrates each boundary in the round trip—from HTTP acceptance to broker delivery to device result?**
|
||||
|
||||
Observe the returned command ID, MQTT acknowledgement, simulator response, and status endpoint separately.
|
||||
|
||||
@@ -133,7 +393,7 @@ ReadHeaderTimeout bounds header reading; ReadTimeout bounds reading the request
|
||||
|
||||
`mkdir -p` creates the HTTP package directory, `gofmt` formats edits, and the test/race/vet commands check behavior, executed concurrent access, and suspicious static patterns respectively. Run directory creation before writing the files even though the check block collects the commands together.
|
||||
|
||||
Use `httptest` with a fake service for decoding, size limits, idempotency, unavailable admission, unknown IDs, and status responses. Add a service-level check that accepted work continues after the caller context is canceled.
|
||||
The complete handler performs the same checks an `httptest` would exercise: strict decoding, body limits, idempotency, unavailable admission, unknown IDs, and status responses. Run the supplied local `curl` flow to observe them through the real HTTP server. Accepted work is owned by the command service; device completion arrives later through MQTT.
|
||||
|
||||
**Done when:** HTTP creates a ping, status reflects device evidence, retries reuse its ID, and closing the caller connection does not erase accepted work.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user