Orchestrate

Report an issue

Something wrong, out of date or unclear? Tell us. Corrections are prioritized over new topics. You don't need to sign in.

0 / 2000

Don't include personal or confidential company details. Reports are reviewed with AI assistance.

Set up for Unit 5B: integration tools

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.

Updated Oct 29, 2026Foundational 7 minDeep 35 min
Foundational layer · 7 min read

The 60-second version

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.

Why it matters to the business

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.

How SAP does it

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).

What this unit adds

Item What it is Cost Used in
HTTP client VS Code's REST Client extension, or curl Free Calling and inspecting APIs in every Unit 5B topic
mock_sap.py A local stand-in for Calder's order inbox, with planned faults Free Simulating synchronous calls and their failures
queue_worker.py A durable queue with retries, backoff and a dead-letter queue Free Simulating asynchronous integration
check_unit5b.py A script that proves the mock and the queue work Free This topic
Integration Suite trial SAP's platform, optional Free trial, time-limited Looking at the real tools; not required

No new Python libraries. All three scripts use only what comes with Python.

Time and money

  • Time: about 35 minutes for the local tools. The optional SAP trial adds about an hour.
  • Money: nothing. The SAP trials are free and time-limited; nothing in this unit needs a paid account.
  • Network: the mock runs on the learner's own computer and needs no internet. Only installing the extension and the optional trial go online.

Questions to ask IT

  • May learners install VS Code extensions from the marketplace, specifically REST Client?
  • Is running a small local web server on 127.0.0.1 allowed, or does endpoint security block it?
  • If a learner wants the optional SAP trial, may they register with a work email, and is sap.com reachable?
  • Which interface errors cost us most today (missing orders, duplicates, stuck invoices)? They make the best practice cases.

Common misconceptions

  • "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.

Key terms

All course terms, A to Z, in the glossary

  • HTTP client: a tool that sends a request to an API and shows the status code, headers and body of the answer.
  • Mock: a small program that imitates a real system so you can test against it safely.
  • Retry with backoff: trying again after a failure, waiting longer each time so a struggling system can recover.
  • Idempotency: sending the same message twice has the same effect as sending it once.
  • Dead-letter queue: where messages that can't be processed are kept for a person to fix and replay.
  • Integration Suite: SAP's integration platform on SAP BTP.

Check yourself

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.
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.

How it works

The mock order inbox

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.

Planned faults

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.

Idempotency: one message, one order

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.

The queue, retries and dead letters

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.

Your HTTP client

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.

What you need

  • Your course folder from Unit 1, with .venv and .env (and SAP_API_KEY in it, for the optional sandbox request).
  • VS Code.
  • About 35 minutes. Free. No internet needed after installing the extension.

Step 1: Install an HTTP client

  1. Open VS Code and choose File > Open Folder, then open orchestrate-course.

  2. Click the Extensions icon on the left (or press Ctrl+Shift+X, Cmd+Shift+X on macOS).

  3. Search for REST Client. Choose the one by Huachao Mao and click Install.

  4. Check that curl is there too, as a fallback. Open a terminal with Terminal > New Terminal and run:

    • Windows (PowerShell):

      curl.exe --version
    • macOS / Linux:

      curl --version

    You should see a line starting with curl 8. or similar. If your company blocks marketplace extensions, use curl for the rest of the unit.

Step 2: Create the mock order inbox

  1. 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
  2. Create the unit folder:

    mkdir unit5b
  3. 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()
  1. 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.

Step 3: Create the queue worker

  1. Open a second terminal: click the + in VS Code's terminal panel. Activate .venv in it as in Step 2.
  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()
  1. 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.

  1. 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.

Step 4: Call the mock and the sandbox by hand

  1. 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
  1. 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".
  2. 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.
  3. 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.
  4. Send requests 3 and 4. Request 4 has no APIKey header and gets 401 Unauthorized.
  5. 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:

{"PurchaseOrderByCustomer": "HFS-PO-7781", "SoldToParty": "10000002",
 "Items": [{"Material": "CP-SK200", "RequestedQuantity": 60}]}
  • Windows (PowerShell):

    curl.exe http://127.0.0.1:8765/health
    curl.exe -i -X POST http://127.0.0.1:8765/calder/inbound/orders -H "APIKey: calder-local-key" -H "Content-Type: application/json" -H "Idempotency-Key: try-it-0002" --data-binary "@unit5b/order.json"
  • macOS / Linux:

    curl http://127.0.0.1:8765/health
    curl -i -X POST http://127.0.0.1:8765/calder/inbound/orders -H "APIKey: calder-local-key" -H "Content-Type: application/json" -H "Idempotency-Key: try-it-0002" --data-binary "@unit5b/order.json"

-i shows the status line and headers above the body. Reading the file with @ avoids quoting problems with JSON in PowerShell.

Step 5: Run the Unit 5B check

  1. Create a new file in the course folder (not in unit5b), paste the script below and save it as check_unit5b.py.

  2. 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.

Step 6: Save your work in Git

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.

What each part of the code does

Part What it does
FAULT_PLAN in the mock Says which customer PO fails how, on which try. No randomness, so every run matches
Inbox.receive() Counts tries, replays known idempotency keys, applies planned faults, validates customer and product, creates the sales order
make_handler() Turns HTTP requests into calls on the inbox and checks the APIKey header
start() Runs the mock in a background thread on any free port; the check script uses it
connect() and enqueue() in the worker Create the SQLite table and add messages, each with its idempotency key made once
send() Posts one message and returns the status, the answer and any Retry-After value; a timeout returns no status
backoff() Exponential wait with jitter from a seeded random generator, capped at MAX_DELAY
run() The decision loop: done, retry or dead letter, saving state after every try
replay command Moves a dead letter back to queued once its cause is fixed
check_unit5b.py Runs the five samples through a throw-away queue against its own mock and checks counts and duplicates

If something goes wrong

What you see What it means What to do
python is not recognized, or command not found The terminal can't find Python Repeat Step 1 of the course setup; on macOS and Linux use python3 until .venv is active
ModuleNotFoundError for a library You added an import that isn't built in These scripts need none; copy them again from this topic
OSError: [Errno 48], [Errno 98] or [WinError 10048] when starting the mock Port 8765 is already in use, often by a mock you started earlier Close the other terminal, or start with --port 8766 and change BASE_URL and @base to match
Every message shows timeout and ends as a dead letter The mock isn't running Start it in the first terminal (Step 2), then reset, enqueue --sample and run again
401 with Missing or wrong APIKey header The header is missing or mistyped Use exactly APIKey: calder-local-key
422 from the mock You reused an idempotency key with a different body Use a new key for a new message
No Send Request link above requests REST Client isn't installed, or the file doesn't end in .http Install it (Step 1) and save the file as calder.http
The sandbox request shows {{$dotenv SAP_API_KEY}} unresolved or 401 sandbox.http isn't next to .env, or the key is wrong Move sandbox.http to the course folder; copy the key again from api.sap.com
PowerShell curl prints a table or an error about parameters curl is an alias for Invoke-WebRequest in PowerShell Type curl.exe
network problem or a proxy error on the sandbox call A company proxy blocks sandbox.api.sap.com Ask IT; the local mock needs no network

Optional: look at SAP Integration Suite

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.

Where this shows up in SAP

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.

Pitfalls

  • Retrying everything. A 400 won't fix itself. Retrying it wastes capacity and delays the person who must correct the data.
  • A new idempotency key per try. The key belongs to the message, not the attempt. A fresh key on each retry turns the protection off.
  • Treating a timeout as a failure. The receiver may have done the work. Only a repeat-safe retry or a status check tells you.
  • Nobody owns the dead letters. A dead-letter queue without an owner and a daily check is a slow data loss.
  • Copying the mock's codes into a design. Idempotent-Replay, the paths and the fault plan are course labels, not SAP behaviour.
  • Pointing the mock at real data. Keep company data out of local tools and trials.

Exercise: stop duplicates by business key

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.

  1. Restart the mock (Ctrl+C in the first terminal, then start it again).
  2. In the second terminal, run reset, then enqueue --sample twice, then run. Run status. Note that HFS-PO-7781 now has two sales orders.
  3. 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.
  4. If one exists, return 409 with an error that names the existing sales order, such as Duplicate of sales order 1000201.
  5. Decide in the worker: is 409 retryable? (It isn't: the order already exists.) Confirm that RETRYABLE doesn't contain it.
  6. Restart the mock and repeat step 2. The second copy of each delivered order should end as a dead letter naming the original.
  7. 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.
  8. 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.

Check yourself

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.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in