Add an HTTP client, a local mock of an SAP order inbox and a small queue with retries and dead letters, so you can practise integration without a tenant.
Unit 5B is about how SAP and non-SAP systems exchange data: APIs, files, IDocs, events and queues. Most integration failures are not exotic. A receiving system is down, slow, overloaded or rejects a message. The skill is designing for those moments.
Learners can practise that without an SAP tenant. This setup adds three small things to the laptop from Unit 1:
An HTTP client, a tool for sending a request to an API and reading the answer. The course uses a free VS Code extension; curl, a command-line tool that comes with Windows 10 and 11, works too.
A local mock of an SAP order inbox: a small Python program that plays the receiving system for the fictional company Calder Pumpworks, and fails on purpose in planned ways.
A small queue with retries and a dead-letter queue: a second program that sends orders, retries the ones that can succeed later, and parks the ones that never will.
It takes about 35 minutes and costs nothing. An SAP Integration Suite trial is optional and not needed for the unit's exercises.
When an interface fails, the cost lands in a business process. A distributor's order that never arrives is a missed sale. An order that arrives twice is a double shipment and a credit note. An invoice stuck in an error queue is a late payment and a supplier on the phone.
Each of those has a design answer: retry what can succeed later, refuse duplicates, and put what can't be processed in front of a person. Teams that test those answers before go-live avoid the classic hypercare weeks spent reprocessing messages by hand.
A local mock makes this cheap to learn. Learners break an interface on purpose, watch what happens, and fix the design, all on a laptop, with no shared system to disturb and no licence to buy.
On a real program, these patterns live in SAP's integration platform and in the systems themselves:
SAP Integration Suite is SAP's integration platform on SAP BTP. As of October 2026, SAP Learning lists capabilities including Cloud Integration (integration flows between cloud and on-premise systems), API Management (publishing and securing APIs, with rate limiting) and Event Mesh (event-based communication) (SAP Learning). Event Mesh supports automatic retries and dead-letter queues (SAP Learning).
Retries and dead letters are standard platform features. Cloud Integration's JMS sender adapter has a dead-letter queue setting that parks a message for a person to restart or delete (SAP blog, 2017).
Duplicates are handled by an "idempotent receiver" that recognizes a message it has already processed, by message ID or a business key such as the order number (SAP blog, 2021).
The mock in this setup is not SAP software. It is a teaching stand-in that shows the same behaviours.
Trial and pricing, as of 29 October 2026. SAP offers a 30-day basic trial of Integration Suite at no cost, on a shared tenant with sample data and without administration rights (sap.com). Developers can also subscribe to Integration Suite inside the 90-day SAP BTP trial (SAP tutorial, sap.com). SAP's pricing page lists paid starter, standard and premium editions and no free tier (sap.com).
"We need an Integration Suite tenant to learn integration." Not to learn the patterns. Retries, duplicates and dead letters behave the same in a local mock. The tenant adds SAP's tools and monitoring.
"Retrying is always safe." Only if the receiver can recognize a repeat. Without that, a retry after a timeout creates a second order.
"A dead-letter queue means the message is lost." It means the opposite: the message is kept, out of the way, for a person to fix and replay.
"There is a free tier for Integration Suite." As of 29 October 2026, SAP's pricing page lists none; there is a time-limited trial.
Pick one answer for each question. The explanation appears after you choose.
1What does a learner need to practise Unit 5B's integration patterns?
Answer: B. The patterns (retries, duplicates, dead letters) behave the same in a local mock. The tools are free and use only Python and VS Code. The Integration Suite trial is optional.
2An order message times out and the sender retries it. What business risk does that create?
Answer: B. A timeout does not mean the receiver failed; it may have saved the order and only the answer was lost. Retrying is safe only when the receiver recognizes a repeat, which is what idempotency means.
3What happens to a message in a dead-letter queue?
Answer: A. A dead-letter queue keeps messages that can't be processed, out of the way of the rest. Someone must own it, fix the cause and replay or delete each message.
4A sales lead says Integration Suite has a free tier you can keep using. What is the accurate answer as of 29 October 2026?
Answer: D. SAP's pricing page lists starter, standard and premium editions and no free tier. The free options are the 30-day basic trial and the 90-day BTP trial, both time-limited.
5Which question to IT is most likely to block the hands-on work if nobody asks it?
Answer: C. The mock is a small local web server and the course's HTTP client is a VS Code extension. Endpoint security sometimes blocks one or the other; curl is the fallback for the extension.
6Why use a mock with planned faults instead of only the happy path?
Answer: C. Missing, duplicate and stuck messages are where interfaces cost the business money. A mock that fails the same way every run lets a team test retries, duplicate checks and dead letters before go-live.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Deep layer · 35 min read
#Mental model: a sender, a receiver, and everything between
Every interface in this unit has the same three parts. A sender has a message. A receiver must act on it exactly once. Between them is a network and, often, a queue that holds the message until it is delivered. Integration design is deciding what happens when any of these misbehaves.
flowchart LR
E[Sample orders] --> Q[(queue.db<br/>SQLite)]
Q --> W[queue_worker.py]
W -->|POST + Idempotency-Key| M[mock_sap.py<br/>Calder order inbox]
M -->|201, 503, 429, slow, 400| W
W -->|cannot succeed| D[(dead letters)]
H[REST Client or curl] -->|you, by hand| M
This setup gives you the receiver (the mock), the queue and sender (the worker), and a way to poke the receiver by hand (the HTTP client). The later Unit 5B topics use them to compare synchronous calls, events and files.
mock_sap.py is a small web server built on Python's http.server module. It plays the part of Calder's S/4HANA system receiving orders from customers and distributors. Python's documentation says this module is not for production, which is fine for a teaching mock that only listens on your own computer (Python docs).
It is not an SAP API. The JSON loosely follows names you met on SAP's sales order API (SoldToParty, PurchaseOrderByCustomer), and lists come back wrapped in {"d": {"results": [...]}}, as OData V2 does, so the shape feels familiar. The paths are course paths.
Path
Method
What it does
/health
GET
Says the server is up. No key needed
/calder/inbound/orders
POST
Receives an order message and creates a sales order
/calder/orders
GET
Lists the sales orders created so far
/calder/orders/<number>
GET
Shows one sales order
Every call except /health needs the header APIKey: calder-local-key, imitating the sandbox's APIKey header from Unit 1. That value is a local course value, not a secret; a real key always goes in .env.
The mock fails on purpose, the same way on every run, based on the customer's purchase order number in the message. All of these are fictional Calder messages:
Customer PO
Customer
What the mock does
Real-world cause
HFS-PO-7781
10000002 Hallam (60 seal kits)
Accepts at once (201)
Normal day
BWU-PO-5602
10000001 Brennock (2 ES-200)
503 Service Unavailable twice, then accepts
Receiver restarting or in maintenance
HFS-PO-7790
10000002 Hallam (4 ES-200)
429 Too Many Requests once with Retry-After: 1, then accepts
Rate limit, such as an API gateway policy
BWU-PO-5610
10000001 Brennock (20 seal kits)
Saves the order, but answers after 3 seconds
Slow receiver; the answer is lost to a timeout
XYZ-PO-0001
10009999 (does not exist)
400 Bad Request every time
Bad master data in the message
The mock creates sales orders from 1000201 upwards so they don't collide with Calder orders in earlier topics. The faults trigger once per mock run: restart the mock to see them again.
The slow case is the important one. The worker gives up waiting after 1.5 seconds and retries. But the mock already saved the order. Without protection, the retry would create a second sales order for the same purchase.
The protection is an idempotency key: a unique value the sender creates once per message and sends with every try in an Idempotency-Key header. The mock remembers each key. A repeat with the same key and body gets the stored answer back, marked with an Idempotent-Replay: true header, and no new order. The same key with a different body gets 422, because that is a sender bug.
This follows an IETF draft for the Idempotency-Key header (version 07, October 2025); it is a draft, not a finished standard (IETF). SAP's own guidance describes the same idea for integration flows: guaranteed delivery plus an idempotent receiver that discards duplicates, by message ID or a business key (SAP blog, 2021). The Idempotent-Replay header is a course label.
queue_worker.py stores messages in one SQLite file, unit5b/queue.db. SQLite is a database in a single file that comes with Python. Because the queue is on disk, a message survives if you stop the worker halfway.
For each queued message the worker decides:
flowchart TD
S[Send with Idempotency-Key] --> A{Answer}
A -->|200 or 201| OK[done]
A -->|429| R1[wait Retry-After]
A -->|500, 502, 503, 504 or no answer| R2[wait: backoff with jitter]
A -->|other 4xx, such as 400 or 422| DL[dead letter: not retryable]
R1 --> C{tries left?}
R2 --> C
C -->|yes| S
C -->|no, after 4 tries| DL
Retry only what can succeed later. A 503 or a timeout may clear. A 400 for an unknown customer never will, so it goes straight to the dead-letter queue.
Exponential backoff with jitter. Waits grow from about 0.5 to 1 to 2 seconds, capped at 4, and are shuffled a little so many senders don't retry at the same instant. The shuffle uses a fixed seed (42), so your waits match the ones shown here.
Respect Retry-After. When the receiver says how long to wait, the worker waits exactly that.
A dead letter keeps the message.status shows why it failed; replay <id> puts it back in the queue once the cause is fixed.
You need a way to send one request by hand and read the full answer. As of October 2026, three free options fit the course:
Client
What it is
Why choose it
REST Client for VS Code
Extension by Huachao Mao, MIT licence. Requests live in a .http text file; click Send Request above one, or press Ctrl+Alt+R (Cmd+Alt+R on macOS) (README)
Stays in VS Code; requests are text you commit with Git. The course default
curl
Command-line client, installed by default on Windows 10 and 11 (curl.se) and often present on macOS and Linux; Step 1 checks whether yours has it
No install. In PowerShell, type curl.exe: plain curl is an alias for a different command
Bruno
Desktop API client that stores collections in a folder, with no cloud sync (Flathub)
If you prefer a window with forms over text files
REST Client can read a value from .env with {{$dotenv NAME}}, but only from the .env in the same folder as the .http file (README). That is why the sandbox request file in Step 4 sits in your course folder, next to .env.
#Build it yourself: a mock inbox, a queue and an HTTP client
You will start a mock of Calder's order inbox, send it orders through a queue that retries and parks failures, and inspect it by hand with an HTTP client. Then a check script proves it all works.
flowchart LR
T1[Terminal 1<br/>mock_sap.py] --- M((127.0.0.1:8765))
T2[Terminal 2<br/>queue_worker.py] --> M
V[VS Code<br/>calder.http] --> M
C[check_unit5b.py] --> O[OK / MISSING / LATER]
Before you start: complete Set up your computer for this course. It creates your orchestrate-course folder with its .venv and .env. This topic installs no Python libraries.
In the terminal, turn on the virtual environment if the prompt doesn't start with (.venv):
Windows (PowerShell):.venv\Scripts\Activate.ps1
macOS / Linux:source .venv/bin/activate
Create the unit folder:
mkdir unit5b
Create a new file, paste the script below, and save it as mock_sap.py inside unit5b.
"""A local stand-in for Calder's S/4HANA order inbox, for Unit 5B.
Calder Pumpworks is a fictional company. Codes, numbers and amounts are
made up for the course. This is not SAP software and not an SAP API: it is a
small web server on your own computer that behaves like a receiving system,
including the ways a receiving system fails.
Run it from your course folder: python unit5b/mock_sap.py
Stop it with Ctrl+C. It listens only on your own computer (127.0.0.1).
What it offers:
GET /health is the server up?
GET /calder/orders orders received so far
GET /calder/orders/<sales order> one received order, OData V2 style
POST /calder/inbound/orders send an order message (JSON)
Faults are planned, not random, so every run behaves the same:
HFS-PO-7781 accepted at once
BWU-PO-5602 503 Service Unavailable twice, then accepted
HFS-PO-7790 429 Too Many Requests once (Retry-After: 1), then accepted
BWU-PO-5610 saved, but the first answer comes too late (client times out)
any unknown customer 400 Bad Request, every time
"""
import argparse
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
API_KEY = "calder-local-key" # a local course value, not a secret
CUSTOMERS = {"10000001": "Brennock Water Utility", "10000002": "Hallam Fluid Systems"}
PRODUCTS = {"CP-ES200", "CP-ES300", "CP-SK200"}
FAULT_PLAN = { # customer PO number -> what goes wrong on attempts 1, 2, ...
"BWU-PO-5602": ["503", "503"],
"HFS-PO-7790": ["429"],
"BWU-PO-5610": ["slow"],
}
SLOW_SECONDS = 3.0
FIRST_SALES_ORDER = 1000201
class Inbox:
"""What the receiving system remembers. One lock keeps threads apart."""
def __init__(self):
self.lock = threading.Lock()
self.attempts = {} # customer PO -> attempts seen
self.by_key = {} # idempotency key -> (body, status, answer)
self.orders = {} # sales order -> order
self.next_number = FIRST_SALES_ORDER
def receive(self, key, body):
"""Return (status, answer, delay, extra headers) for one POST."""
with self.lock:
po = str(body.get("PurchaseOrderByCustomer", ""))
attempt = self.attempts[po] = self.attempts.get(po, 0) + 1
if key in self.by_key:
old_body, status, answer = self.by_key[key]
if old_body != body:
return 422, {"error": "Idempotency-Key reused with a different message"}, 0, {}
return status, answer, 0, {"Idempotent-Replay": "true"}
plan = FAULT_PLAN.get(po, [])
fault = plan[attempt - 1] if attempt <= len(plan) else ""
if fault == "503":
return 503, {"error": "Service unavailable, try again later"}, 0, {}
if fault == "429":
return 429, {"error": "Too many requests"}, 0, {"Retry-After": "1"}
problem = self.validate(body)
if problem:
return 400, {"error": problem}, 0, {}
number = str(self.next_number)
self.next_number += 1
order = {"SalesOrder": number, "SalesOrganization": "CPUS", "SoldToParty": body["SoldToParty"],
"SoldToPartyName": CUSTOMERS[body["SoldToParty"]],
"PurchaseOrderByCustomer": po, "to_Item": body["Items"]}
self.orders[number] = order
answer = {"SalesOrder": number, "PurchaseOrderByCustomer": po}
self.by_key[key] = (body, 201, answer)
return 201, answer, SLOW_SECONDS if fault == "slow" else 0, {}
@staticmethod
def validate(body):
if body.get("SoldToParty") not in CUSTOMERS:
return f"Customer {body.get('SoldToParty')} does not exist"
items = body.get("Items") or []
if not items:
return "Order has no items"
for item in items:
if item.get("Material") not in PRODUCTS:
return f"Product {item.get('Material')} does not exist"
if not isinstance(item.get("RequestedQuantity"), int) or item["RequestedQuantity"] <= 0:
return f"Quantity for {item.get('Material')} must be a positive whole number"
return ""
def make_handler(inbox, quiet=False):
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # one short line per request
if not quiet:
print(f" mock {self.command} {self.path} -> {args[1] if len(args) > 1 else ''}")
def send_json(self, status, data, headers=None):
raw = json.dumps(data, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
try:
self.wfile.write(raw)
except OSError:
pass # the client stopped waiting; the work is already saved
def authorized(self):
if self.headers.get("APIKey") == API_KEY:
return True
self.send_json(401, {"error": "Missing or wrong APIKey header"})
return False
def do_GET(self):
if self.path == "/health":
return self.send_json(200, {"status": "up"})
if not self.authorized():
return
if self.path == "/calder/orders":
with inbox.lock:
rows = [{"SalesOrder": o["SalesOrder"], "PurchaseOrderByCustomer": o["PurchaseOrderByCustomer"],
"SoldToParty": o["SoldToParty"]} for o in inbox.orders.values()]
return self.send_json(200, {"d": {"results": rows}})
prefix = "/calder/orders/"
if self.path.startswith(prefix):
with inbox.lock:
order = inbox.orders.get(self.path[len(prefix):])
if order:
return self.send_json(200, {"d": order})
return self.send_json(404, {"error": "No such sales order"})
self.send_json(404, {"error": "Unknown path"})
def do_POST(self):
if self.path != "/calder/inbound/orders":
return self.send_json(404, {"error": "Unknown path"})
if not self.authorized():
return
key = self.headers.get("Idempotency-Key", "")
if not key:
return self.send_json(400, {"error": "Idempotency-Key header is required"})
try:
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length) or b"{}")
except (ValueError, json.JSONDecodeError):
return self.send_json(400, {"error": "Body is not valid JSON"})
status, answer, delay, headers = inbox.receive(key, body)
if delay:
time.sleep(delay)
self.send_json(status, answer, headers)
return Handler
def start(port=0, quiet=True):
"""Start the mock in a background thread; return (server, inbox). Used by the check script."""
inbox = Inbox()
server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(inbox, quiet))
threading.Thread(target=server.serve_forever, daemon=True).start()
return server, inbox
def main():
parser = argparse.ArgumentParser(description="Local mock of Calder's order inbox (fictional).")
parser.add_argument("--port", type=int, default=8765, help="port to listen on (default 8765)")
args = parser.parse_args()
server = ThreadingHTTPServer(("127.0.0.1", args.port), make_handler(Inbox()))
print(f"Calder mock order inbox on http://127.0.0.1:{args.port} (fictional data, Ctrl+C to stop)")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
if __name__ == "__main__":
main()
Start it from the course folder:
Windows (PowerShell):
python unit5b\mock_sap.py
macOS / Linux:
python unit5b/mock_sap.py
You should see:
Calder mock order inbox on http://127.0.0.1:8765 (fictional data, Ctrl+C to stop)
Leave this terminal running. Each request will print one line here. If your firewall asks whether Python may accept connections, you can refuse outside networks: the mock only needs your own computer.
Open a second terminal: click the + in VS Code's terminal panel. Activate .venv in it as in Step 2.
Create a new file, paste the script below, and save it as queue_worker.py inside unit5b.
"""A tiny durable queue with retries and a dead-letter queue, for Unit 5B.
Calder Pumpworks is fictional; the messages are made-up distributor and
customer orders. The queue is one SQLite file (unit5b/queue.db), so messages
survive if you stop the worker halfway.
Run these from your course folder, with the mock running in another terminal:
python unit5b/queue_worker.py reset empty the queue
python unit5b/queue_worker.py enqueue --sample put five sample orders in
python unit5b/queue_worker.py run send them, retrying as needed
python unit5b/queue_worker.py status show every message's state
python unit5b/queue_worker.py replay <id> move a dead letter back to the queue
"""
import argparse
import json
import random
import sqlite3
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
DB = Path(__file__).resolve().parent / "queue.db"
BASE_URL = "http://127.0.0.1:8765"
API_KEY = "calder-local-key" # the mock's local course value, not a secret
MAX_ATTEMPTS = 4 # first try plus three retries
BASE_DELAY = 0.5 # seconds before the first retry
MAX_DELAY = 4.0 # never wait longer than this between tries
TIMEOUT = 1.5 # seconds to wait for an answer
RETRYABLE = {429, 500, 502, 503, 504}
SEED = 42 # fixed, so the jitter is the same on every run
SAMPLE = [ # fictional inbound orders, as a distributor or customer system would send them
{"PurchaseOrderByCustomer": "HFS-PO-7781", "SoldToParty": "10000002",
"Items": [{"Material": "CP-SK200", "RequestedQuantity": 60}]},
{"PurchaseOrderByCustomer": "BWU-PO-5602", "SoldToParty": "10000001",
"Items": [{"Material": "CP-ES200", "RequestedQuantity": 2}]},
{"PurchaseOrderByCustomer": "HFS-PO-7790", "SoldToParty": "10000002",
"Items": [{"Material": "CP-ES200", "RequestedQuantity": 4}]},
{"PurchaseOrderByCustomer": "BWU-PO-5610", "SoldToParty": "10000001",
"Items": [{"Material": "CP-SK200", "RequestedQuantity": 20}]},
{"PurchaseOrderByCustomer": "XYZ-PO-0001", "SoldToParty": "10009999",
"Items": [{"Material": "CP-ES300", "RequestedQuantity": 1}]},
]
def connect(db=DB):
con = sqlite3.connect(db)
con.execute("""CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY, idem_key TEXT UNIQUE, body TEXT, state TEXT,
attempts INTEGER DEFAULT 0, last_result TEXT DEFAULT '', sales_order TEXT DEFAULT '')""")
return con
def enqueue(con, body):
"""Add one message. The idempotency key is made once, here, and reused on every retry."""
con.execute("INSERT INTO messages (idem_key, body, state) VALUES (?, ?, 'queued')",
(str(uuid.uuid4()), json.dumps(body)))
con.commit()
def send(base_url, key, body):
"""POST one message. Return (status or None, answer dict, Retry-After seconds or None)."""
request = urllib.request.Request(
f"{base_url}/calder/inbound/orders", data=json.dumps(body).encode("utf-8"), method="POST",
headers={"Content-Type": "application/json", "APIKey": API_KEY, "Idempotency-Key": key})
try:
with urllib.request.urlopen(request, timeout=TIMEOUT) as resp:
answer = json.loads(resp.read() or b"{}")
answer["replayed"] = resp.headers.get("Idempotent-Replay") == "true"
return resp.status, answer, None
except urllib.error.HTTPError as err:
retry_after = err.headers.get("Retry-After")
return err.code, json.loads(err.read() or b"{}"), float(retry_after) if retry_after else None
except (urllib.error.URLError, TimeoutError, OSError) as err:
reason = getattr(err, "reason", err)
return None, {"error": f"no answer ({type(reason).__name__})"}, None
def backoff(attempt, rng):
"""Exponential backoff with jitter: about 0.5 s, 1 s, 2 s ... never above MAX_DELAY."""
delay = min(MAX_DELAY, BASE_DELAY * 2 ** (attempt - 1))
return round(delay * (0.5 + rng.random() / 2), 2)
def run(con, base_url=BASE_URL, out=print, sleep=time.sleep):
"""Work through the queue once. Return counts of each final state."""
rng = random.Random(SEED)
rows = con.execute("SELECT id, idem_key, body, attempts FROM messages WHERE state = 'queued' ORDER BY id").fetchall()
for msg_id, key, raw, attempts in rows:
body = json.loads(raw)
po = body["PurchaseOrderByCustomer"]
while True:
attempts += 1
status, answer, retry_after = send(base_url, key, body)
shown = status if status else "timeout"
if status in (200, 201):
note = " (replayed: already saved on an earlier try)" if answer["replayed"] else ""
out(f" #{msg_id} {po} try {attempts}: {shown} -> sales order {answer['SalesOrder']}{note}")
con.execute("UPDATE messages SET state='done', attempts=?, last_result=?, sales_order=? WHERE id=?",
(attempts, str(status), answer["SalesOrder"], msg_id))
break
retryable = status is None or status in RETRYABLE
if not retryable or attempts >= MAX_ATTEMPTS:
why = "not retryable" if not retryable else f"gave up after {attempts} tries"
out(f" #{msg_id} {po} try {attempts}: {shown} {answer.get('error', '')} -> dead letter ({why})")
con.execute("UPDATE messages SET state='dead', attempts=?, last_result=? WHERE id=?",
(attempts, f"{shown} {answer.get('error', '')}".strip(), msg_id))
break
wait = retry_after if retry_after is not None else backoff(attempts, rng)
source = "Retry-After" if retry_after is not None else "backoff"
out(f" #{msg_id} {po} try {attempts}: {shown} -> retry in {wait:.2f} s ({source})")
con.execute("UPDATE messages SET attempts=?, last_result=? WHERE id=?", (attempts, str(shown), msg_id))
con.commit()
sleep(wait)
con.commit()
return dict(con.execute("SELECT state, COUNT(*) FROM messages GROUP BY state").fetchall())
def status(con):
print(f" {'id':>3} {'customer PO':<12} {'state':<7} {'tries':>5} {'sales order':<11} last result")
for row in con.execute("SELECT id, body, state, attempts, sales_order, last_result FROM messages ORDER BY id"):
po = json.loads(row[1])["PurchaseOrderByCustomer"]
print(f" {row[0]:>3} {po:<12} {row[2]:<7} {row[3]:>5} {row[4] or '-':<11} {row[5]}")
def main():
parser = argparse.ArgumentParser(description="Durable queue with retries and dead letters (fictional data).")
parser.add_argument("command", choices=["reset", "enqueue", "run", "status", "replay"])
parser.add_argument("id", nargs="?", type=int, help="message id, for replay")
parser.add_argument("--sample", action="store_true", help="enqueue the five sample orders")
args = parser.parse_args()
con = connect()
if args.command == "reset":
con.execute("DELETE FROM messages")
con.commit()
print("Queue is empty.")
elif args.command == "enqueue":
if not args.sample:
sys.exit("Use: enqueue --sample")
for body in SAMPLE:
enqueue(con, body)
print(f"Queued {len(SAMPLE)} sample orders (fictional).")
elif args.command == "run":
counts = run(con)
print(f"\nDone: {counts.get('done', 0)} delivered, {counts.get('dead', 0)} in the dead-letter queue, "
f"{counts.get('queued', 0)} still queued.")
elif args.command == "status":
status(con)
elif args.command == "replay":
changed = con.execute("UPDATE messages SET state='queued', attempts=0 WHERE id=? AND state='dead'",
(args.id,)).rowcount
con.commit()
print(f"Message {args.id} is queued again." if changed else f"Message {args.id} is not a dead letter.")
if __name__ == "__main__":
main()
Put the five sample orders in the queue and send them. On Windows use unit5b\queue_worker.py; the commands are otherwise the same:
python unit5b/queue_worker.py reset
python unit5b/queue_worker.py enqueue --sample
python unit5b/queue_worker.py run
You should see:
Queue is empty.
Queued 5 sample orders (fictional).
#1 HFS-PO-7781 try 1: 201 -> sales order 1000201
#2 BWU-PO-5602 try 1: 503 -> retry in 0.41 s (backoff)
#2 BWU-PO-5602 try 2: 503 -> retry in 0.51 s (backoff)
#2 BWU-PO-5602 try 3: 201 -> sales order 1000202
#3 HFS-PO-7790 try 1: 429 -> retry in 1.00 s (Retry-After)
#3 HFS-PO-7790 try 2: 201 -> sales order 1000203
#4 BWU-PO-5610 try 1: timeout -> retry in 0.32 s (backoff)
#4 BWU-PO-5610 try 2: 201 -> sales order 1000204 (replayed: already saved on an earlier try)
#5 XYZ-PO-0001 try 1: 400 Customer 10009999 does not exist -> dead letter (not retryable)
Done: 4 delivered, 1 in the dead-letter queue, 0 still queued.
The run takes about four seconds. The waits are the same on every run because the jitter uses a fixed seed. Message #4 is the lesson: the first try timed out, but the order already existed, and the retry got the stored answer instead of creating a second order.
See every message's state:
python unit5b/queue_worker.py status
id customer PO state tries sales order last result
1 HFS-PO-7781 done 1 1000201 201
2 BWU-PO-5602 done 3 1000202 201
3 HFS-PO-7790 done 2 1000203 201
4 BWU-PO-5610 done 2 1000204 201
5 XYZ-PO-0001 dead 1 - 400 Customer 10009999 does not exist
If you run enqueue --sample and run again without restarting the mock, the faults don't repeat (each happens once per mock run) and the mock creates four more orders, because the new messages carry new idempotency keys. That second point is the subject of the exercise.
In VS Code, create a new file, paste the lines below, and save it as calder.http inside unit5b:
# Calder mock order inbox (fictional). Start it first: python unit5b/mock_sap.py
@base = http://127.0.0.1:8765
@key = calder-local-key
### 1. Is the mock up?
GET {{base}}/health
### 2. Send one order. Send it twice: the second answer is a replay, not a new order.
POST {{base}}/calder/inbound/orders
APIKey: {{key}}
Content-Type: application/json
Idempotency-Key: try-it-0001
{"PurchaseOrderByCustomer": "HFS-PO-7781", "SoldToParty": "10000002",
"Items": [{"Material": "CP-SK200", "RequestedQuantity": 60}]}
### 3. List the orders the mock has received
GET {{base}}/calder/orders
APIKey: {{key}}
### 4. A call without the APIKey header is refused (401)
GET {{base}}/calder/orders
With the mock still running, click Send Request above request 1. A panel opens on the right with HTTP/1.0 200 OK and "status": "up".
Send request 2. The answer is 201 Created with a new sales order number. Send it again: same number, and the headers now include Idempotent-Replay: true.
Change 60 to 61 in request 2's body and send it once more. The answer is 422: the key was reused with a different message.
Send requests 3 and 4. Request 4 has no APIKey header and gets 401 Unauthorized.
Optional, to call SAP's sandbox the same way: create a file sandbox.http in your course folder (next to .env, not in unit5b) with these lines, and click Send Request. You should see one sales order number from SAP's demo data, not Calder's.
# SAP Business Accelerator Hub sandbox (SAP's demo data, not Calder's).
# Reads SAP_API_KEY from the .env file in this same folder. Never paste the key here.
### One sales order number from the sandbox
GET https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=1&$select=SalesOrder&$format=json
APIKey: {{$dotenv SAP_API_KEY}}
Accept: application/json
Prefer curl? The same calls from the course folder, with the order body saved first as unit5b/order.json:
Create a new file in the course folder (not in unit5b), paste the script below and save it as check_unit5b.py.
Run it in the second terminal. It starts its own copy of the mock on a free port, so the first terminal can keep running or not:
python check_unit5b.py
"""Check that your computer is ready for Unit 5B (integration architecture).
Run it from your course folder: python check_unit5b.py
It starts its own copy of the Calder mock on a free port, sends the five
sample orders through a throw-away queue, and checks the results. It does
not touch unit5b/queue.db, needs no internet and prints no keys.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
UNIT = Path("unit5b")
problems = 0
def report(ok: bool, label: str, fix: str = "", optional: bool = False) -> None:
"""Print one line: OK, MISSING (must fix) or LATER (optional for now)."""
global problems
if ok:
print(f" OK {label}")
elif optional:
print(f" LATER {label} -> {fix}")
else:
problems += 1
print(f" MISSING {label} -> {fix}")
def get_status(url: str, headers: dict) -> int:
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=5) as resp:
return resp.status
except urllib.error.HTTPError as err:
return err.code
print("\n1. Python")
v = sys.version_info
report(v >= (3, 10), f"Python {v.major}.{v.minor}.{v.micro}", "install Python 3.10 or newer (course setup topic)")
report(sys.prefix != sys.base_prefix, "virtual environment is active", "activate .venv (course setup topic, Step 4)")
print("\n2. Unit 5B files")
files = ["mock_sap.py", "queue_worker.py", "calder.http"]
for name in files:
report((UNIT / name).exists(), f"unit5b/{name}", "create it (this topic, Steps 2 to 4)")
print("\n3. Mock and queue")
if (UNIT / "mock_sap.py").exists() and (UNIT / "queue_worker.py").exists():
sys.path.insert(0, str(UNIT.resolve()))
import mock_sap
import queue_worker
server, inbox = mock_sap.start(port=0)
base = f"http://127.0.0.1:{server.server_address[1]}"
report(get_status(f"{base}/health", {}) == 200, "mock answers on this computer")
report(get_status(f"{base}/calder/orders", {}) == 401, "mock refuses a call without the APIKey header")
with tempfile.TemporaryDirectory() as tmp:
con = queue_worker.connect(Path(tmp) / "check.db")
for body in queue_worker.SAMPLE:
queue_worker.enqueue(con, body)
lines = []
counts = queue_worker.run(con, base_url=base, out=lines.append, sleep=lambda s: None)
con.close()
report(counts.get("done") == 4 and counts.get("dead") == 1,
f"queue delivered {counts.get('done', 0)}, dead-lettered {counts.get('dead', 0)} (expected 4 and 1)",
"copy queue_worker.py and mock_sap.py again from this topic")
retried = sum("retry in" in line for line in lines)
report(retried == 4, f"{retried} retries for the planned faults (expected 4)", "copy both scripts again")
report(len(inbox.orders) == 4, f"{len(inbox.orders)} sales orders created, no duplicates (expected 4)",
"check that each message keeps one Idempotency-Key for all its tries")
server.shutdown()
else:
report(False, "mock and queue test", "create both scripts first (Steps 2 and 3)")
print("\n4. HTTP clients")
curl = shutil.which("curl.exe" if os.name == "nt" else "curl") or shutil.which("curl")
report(bool(curl), "curl is installed", "Windows 10 and 11 include it; on macOS and Linux check with your package manager", optional=True)
code = shutil.which("code")
extensions = ""
if code:
try:
extensions = subprocess.run([code, "--list-extensions"], capture_output=True, text=True,
timeout=60).stdout.lower()
except (OSError, subprocess.SubprocessError):
extensions = ""
report("humao.rest-client" in extensions, "VS Code REST Client extension",
"install it in VS Code (Step 1), or skip this if you use curl", optional=True)
print()
if problems:
print(f"{problems} item(s) to fix. Fix them in order, then run this again.")
sys.exit(1)
print("All set. You are ready for Unit 5B.")
When everything is ready you see:
1. Python
OK Python 3.13.18
OK virtual environment is active
2. Unit 5B files
OK unit5b/mock_sap.py
OK unit5b/queue_worker.py
OK unit5b/calder.http
3. Mock and queue
OK mock answers on this computer
OK mock refuses a call without the APIKey header
OK queue delivered 4, dead-lettered 1 (expected 4 and 1)
OK 4 retries for the planned faults (expected 4)
OK 4 sales orders created, no duplicates (expected 4)
4. HTTP clients
OK curl is installed
OK VS Code REST Client extension
All set. You are ready for Unit 5B.
Your Python version may differ. The REST Client line shows LATER if VS Code's code command isn't on your PATH or you chose curl instead; that is fine. The check skips the waits, so it finishes in about two seconds.
No new lines for requirements.txt: the three scripts use only Python's built-in modules.
git add unit5b/mock_sap.py unit5b/queue_worker.py unit5b/calder.http check_unit5b.py
git commit -m "Add Unit 5B mock inbox, queue and check"
Leave queue.db out: it is local state, rebuilt by reset and enqueue. To keep it out for good, add a line unit5b/queue.db to your .gitignore. sandbox.http holds no key, only the {{$dotenv SAP_API_KEY}} placeholder, so it is safe to commit.
As of 29 October 2026 there are two free ways in, both time-limited:
Option
What you get
Limits
Integration Suite basic trial
A guided, shared tenant with sample data (sap.com)
30 days; a subset of the product, without key user or administration rights
Integration Suite in the SAP BTP trial
Your own subscription in a BTP trial account, following SAP's tutorial: start the trial, subscribe and assign roles, then provision and activate capabilities (SAP tutorial)
90 days from registration, log in at least every 30 days, everything deleted at the end (sap.com); not for productive use or team development
Adding capabilities needs the Integration_Provisioner role (SAP Learning). Once Cloud Integration is active, look for the same ideas you built locally: where an integration flow retries, where failed messages are parked, and how a person restarts them. Don't connect a trial to company systems or load company data into it.
The three behaviours you simulated are the ones an integration lead designs and tests on every SAP program:
Retry and park. Event Mesh supports automatic retries and dead-letter queues for events that can't be processed (SAP Learning). Cloud Integration's JMS sender adapter can park a message in a dead-letter queue for a person to restart or delete (SAP blog, 2017).
Refuse duplicates. SAP's integration flow guidance treats "exactly once" as guaranteed delivery plus an idempotent receiver, and recommends detecting duplicates by the protocol's message ID or a payload key such as the order number (SAP blog, 2021).
Throttle. API Management lists rate limiting and spike arrest among its traffic policies (SAP Learning). A 429 from a gateway is that policy at work, and a well-behaved sender waits.
Exact settings and adapter options change between releases; check the current SAP Help Portal pages for your tenant before you design with them. How IDocs, RFC, OData, SOAP and events compare is the next Unit 5B topic.
The idempotency key protects retries of one message. It doesn't help when a distributor's system sends the same purchase order twice as two messages, each with its own key. That happens in real EDI traffic.
Restart the mock (Ctrl+C in the first terminal, then start it again).
In the second terminal, run reset, then enqueue --sampletwice, then run. Run status. Note that HFS-PO-7781 now has two sales orders.
Open unit5b/mock_sap.py. In Inbox.receive(), before a new sales order is created, look for an existing order in self.orders with the same SoldToParty and PurchaseOrderByCustomer.
If one exists, return 409 with an error that names the existing sales order, such as Duplicate of sales order 1000201.
Decide in the worker: is 409 retryable? (It isn't: the order already exists.) Confirm that RETRYABLE doesn't contain it.
Restart the mock and repeat step 2. The second copy of each delivered order should end as a dead letter naming the original.
In a new file unit5b/duplicates.md, write three sentences: why the idempotency key didn't catch this, who at Calder should own these dead letters, and what they do with them.
Commit both files.
Done when: a second run of the doubled sample shows 4 delivered and 6 dead letters (4 duplicates, each naming its original, plus both copies of the unknown-customer order), python check_unit5b.py still prints All set (only LATER lines allowed), and duplicates.md is committed. Keep the mock: the next Unit 5B topic breaks a synchronous and an event-driven interface against it.
Pick one answer for each question. The explanation appears after you choose.
1The worker gets a 400 Customer 10009999 does not exist. Why does it go straight to the dead-letter queue?
Answer: A. A 400 means the message itself is wrong; sending it again gives the same answer. Retries are for answers that can clear, such as 503 or a timeout. The dead letter keeps the message for a person to correct.
2Message BWU-PO-5610 timed out on the first try. Why did the retry not create a second sales order?
Answer: A. The mock had already saved the order and remembered the key. The retry used the same key and body, so it got the stored answer with Idempotent-Replay: true instead of a new order.
3A colleague changes enqueue() so a new idempotency key is made on every try. What breaks?
Answer: B. The key must belong to the message, not the attempt. With a new key per try, the mock can't tell a retry from a new order, so the timeout case creates two orders. The mock doesn't check business keys until you add that in the exercise.
4Why does the worker wait exactly 1 second after the 429 instead of using its backoff?
Answer: A. run() uses the Retry-After value when the answer has one and falls back to backoff otherwise. Rate limits such as an API gateway policy tell senders how long to back off.
5Your REST Client request to the sandbox shows {{$dotenv SAP_API_KEY}} instead of using your key. What is wrong?
Answer: B. REST Client's $dotenv reads the .env in the same folder as the .http file. Put sandbox.http in the course folder next to .env. Never paste the key into the file.
6In the exercise, the same purchase order arrives as two messages with different keys. What does the fix add?
Answer: D. Idempotency keys only link tries of one message. A business-key check on SoldToParty and PurchaseOrderByCustomer catches a resend of the same purchase order. 409 isn't retryable, so the copy lands in the dead-letter queue for a person.
7Your mock and queue behave exactly as shown. What can you claim about SAP Integration Suite from this?
Answer: C. The mock is a teaching stand-in with course labels and made-up timings. SAP's platform has its own retry, dead-letter and duplicate settings, which you check in the current SAP documentation for the tenant.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Related topics
Picked from the terms, links and tags this topic shares with others.
curl shipped by Microsoft (curl.se)— curl installed by default on Windows 10 and 11; PowerShell's curl is an alias for Invoke-WebRequest, so call curl.exe
Bruno (Flathub listing)— git-friendly API client that stores collections in a folder, no cloud sync, most features MIT-licensed; v4.3.0 listed in October 2026