Most of an AI engineer's code is not "AI". It is plumbing: getting the right business data out of one system, sending it to a model, and putting the answer somewhere a person will use it. That plumbing is built from two things.
APIs. An API (application programming interface) is a door one program offers so other programs can ask it for data or ask it to do something. SAP S/4HANA offers APIs for sales orders, business partners, invoices and much more. Every large language model is also offered as an API.
Python. A programming language that reads almost like English and has ready-made libraries for calling APIs, handling data and working with AI models. It is the default language of AI work.
Put together: a short Python program asks SAP's API for blocked sales orders, turns the answer into a few numbers, and asks a model's API to explain them. That pattern appears in nearly every topic of this course.
When a vendor or a team says "we'll just connect it through the API", a lot is hidden in that sentence. Leaders who understand the basics ask better questions and spot risks earlier.
Take the running example, blocked sales orders in order-to-cash. An AI assistant that helps credit analysts needs to:
Read blocked orders from S/4HANA through an API, with an identity SAP accepts.
Reduce hundreds of fields to the few that matter, so the model sees what it needs and nothing sensitive it doesn't.
Call a model's API, which costs money per request and can be slow or unavailable.
Handle failures: a wrong key, a network block, SAP being down for maintenance.
Each step has a cost and a risk. Steps 1 and 2 decide what data leaves SAP. Step 3 decides the running cost. Step 4 decides whether the tool is trusted after its first bad morning. None of that is about model choice.
SAP Business Accelerator Hub (api.sap.com) is SAP's catalog of APIs for its products, including S/4HANA. It offers sandbox APIs with test data, a Try Out feature to call an API from the browser, and an API key for the sandbox after you log on with an SAP account.
OData is the protocol behind many SAP APIs. It lets a caller ask for exactly the rows and columns it needs, using options such as $filter, $select and $top.
In a real S/4HANA Cloud system, an API isn't open by default. An administrator activates it with a communication arrangement for the matching communication scenario, and gives the calling system a technical communication user. SAP Learning mentions basic (user name and password) and certificate-based authentication.
This split matters for planning: learning and prototyping can start on the sandbox today, but connecting to your company's system is a configuration and security task with its own lead time.
"An API is a product feature you switch on." In a real system, someone has to activate it, create a technical user and grant permissions. Plan the time.
"The sandbox proves it works with our system." The sandbox proves the code works with SAP's API shape and test data. Your system's configuration, data and security are different.
"AI engineers need to be expert programmers." They need a solid small core, done carefully: timeouts, error handling, no keys in code. That core is learnable in weeks.
"More data makes the AI better." Sending every field costs more, slows answers and leaks data. Ask for the fields you need.
Pick one answer for each question. The explanation appears after you choose.
1Why is most of an AI engineer's code "plumbing" rather than AI?
Answer: A. The work is getting the right business data out of a system through its API, sending it to a model's API, and putting the answer where a person will use it. Python and APIs are the building blocks of that plumbing.
2Of the blocked-orders assistant's steps, which decide what data leaves SAP?
Answer: D. Read blocked orders through an API, reduce the fields to what matters, call a model's API, and handle failures. Reading and reducing decide what data leaves SAP; the model call decides running cost; failure handling decides whether people trust the tool.
3What does SAP Business Accelerator Hub give a team that is starting out?
Answer: C. SAP's catalog of APIs, with documentation, sandbox APIs with test data, a Try Out feature to call an API from the browser, and an API key for the sandbox. Learning and prototyping can start there today.
4What has to happen before a team can call your company's S/4HANA Cloud system?
Answer: B. An administrator activates the API with a communication arrangement for its communication scenario and gives the calling system a technical communication user. It is a configuration and security task with its own lead time.
5Which business question does an API call's method (GET, POST, PATCH, DELETE) raise?
Answer: A. The method says whether a call reads (GET) or changes data (POST, PATCH, DELETE). The other questions come from the other parts of a call: the address (production or test), the credentials (whose identity) and the answer (what happens on an error).
6Why is "more data makes the AI better" a misconception?
Answer: D. Sending every field costs more, slows answers and leaks data. Asking only for the fields you need is cheaper, faster and safer.
7A vendor says "we'll just connect it through the API". Which question matters most for data protection?
Answer: C. Which fields leave SAP, and where they go, decides what data could reach an external model provider. Also ask which APIs it calls, whether it can change data, which user it calls SAP with and what happens on errors.
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 · 38 min read
#Mental model: a question in a letter, an answer in a letter
An API call is an exchange of two letters.
Your program writes a request: an address, a verb ("get me", "create this"), some envelope notes (headers, such as your key) and the question itself (parameters, or a body of data). The server writes back a response: a three-digit status code that says how it went, some envelope notes, and the answer, usually as JSON.
The second half of the idea is what makes Python a good fit: JSON turns into Python's own building blocks. A JSON object becomes a Python dictionary, a JSON array becomes a list, and so on. Once an API answer is in your program, you are just working with dictionaries and lists.
sequenceDiagram
participant P as Your Python program
participant S as SAP API
participant M as Model API
P->>S: GET sales orders, key, filter
S-->>P: 200 OK, JSON records
P->>P: Reduce records to a few numbers
P->>M: POST a prompt with those numbers
M-->>P: 200 OK, JSON with the model's text
That diagram is the skeleton of the blocked-orders assistant from What an SAP FDE does, and of most things you build in this course. The model call is also an API call. Learn the pattern once and you can call SAP, a model provider, a vector database or a ticketing system the same way.
Which service (API_SALES_ORDER_SRV) and which set of records (A_SalesOrder)
Query parameters
$top=2&$select=SalesOrder,SoldToParty
The question: how many rows, which fields. They come after ?, joined by &
Headers
APIKey, Accept
Envelope notes: who you are, which format you want back
Body
none for GET
The data you send when you create or change something
In Python with the requests library, the same request is one line. You pass the parameters and headers as dictionaries and requests builds the address for you:
# Sketch: shows the shape of the call. The runnable version is in "Build it yourself".
resp = requests.get(url, params={"$top": "2"}, headers={"APIKey": key}, timeout=30)
Always pass timeout. The Requests documentation warns that without it your program can hang indefinitely; nearly all production code should set it.
Every response starts with a status code. The first digit tells you the class, per MDN: 1xx informational, 2xx success, 3xx redirection, 4xx client error (your request was wrong), 5xx server error (the server failed).
Code
Name
What it usually means for you
200
OK
It worked; the answer is in the body
201
Created
A create request worked
400
Bad Request
The server couldn't understand your question: check parameters and syntax
401
Unauthorized
Missing or wrong credentials
403
Forbidden
The server knows who you are, but you aren't allowed
404
Not Found
Wrong address, or the record doesn't exist
429
Too Many Requests
You hit a rate limit: wait and slow down
500
Internal Server Error
The server failed; not your fault, but you must handle it
The Python documentation's conversion table is short enough to learn by heart:
JSON
Python
object {...}
dict
array [...]
list
string "..."
str
number
int or float
true / false
True / False
null
None
With requests, resp.json() does the conversion. SAP's OData V2 APIs wrap the list of records in an outer object: the records sit under d, then results. So the list of orders is resp.json()["d"]["results"].
One trap: in the sales order data used in this course, amounts such as TotalNetAmount arrive as text ("18250.00"), not numbers. Convert with float(...) before you add them up. For money in production code, Python's decimal module avoids rounding surprises.
OData lets you shape the answer in the request instead of downloading everything and throwing most of it away. SAP Learning's OData course lists the main query options:
Option
What it does
Example
$select
Only these fields
$select=SalesOrder,SoldToParty
$filter
Only rows that match
$filter=SoldToParty eq 'CUST-A'
$top
At most this many rows
$top=50
$skip
Skip this many rows first, for paging
$top=50&$skip=50 for the second page
$orderby
Sort
$orderby=TotalNetAmount desc
$expand
Include related records in one call
Items with their order
$inlinecount (V2), $count (V4)
Include the total number of matches
$inlinecount=allpages
$format
Answer format
$format=json
For AI work, $select matters most. Every field you don't request is a field that can't leak into a prompt, can't cost tokens and can't confuse the model.
AI engineering uses a small core of Python again and again. The script below shows all of it on one sales order. You will run it in Step 2 of the walkthrough; read it now to see the shapes.
Idea
Why an AI engineer needs it
Variables and types
Know whether "18250.00" is text or a number
f-strings
Build messages and prompts from data
Dictionaries
One API record is a dictionary
Lists
An API answer is a list of records
Loops and if
Go through records, keep the ones that matter
List comprehensions
Filter and reshape records in one line
Functions
Name a rule once (for example "is this order blocked?") and test it
json.loads / json.dumps
Read API text into Python; write Python back as JSON
try / except
Handle missing fields and failed calls without crashing
#Build it yourself: an API lab on your own computer
You will run two small programs. The first shows the Python core from the table above on one sales order. The second, the API lab, starts a small practice API on your own computer that behaves like SAP's Sales Order API. It then calls that API six times to show a missing key, a good call, $select, $filter, a wrong address and turning records into numbers. The same client code can then point at SAP's real sandbox and, optionally, at a language model.
flowchart LR
L["api_lab.py: client"] -->|GET with key| P["Practice API on your computer"]
L -.->|"--sap"| S["SAP sandbox"]
L -.->|"--llm"| M["Model API"]
P -->|JSON| L
L --> O["Status codes and a summary"]
The practice API needs no account and no internet connection, so every reader can see every result, even behind a company firewall.
Before you start: complete Set up your computer for this course. It installs Python, VS Code and Git, creates your orchestrate-course folder with its .venv virtual environment, installs requests, anthropic and python-dotenv, and stores your keys in .env. This walkthrough doesn't repeat those steps.
In VS Code's file list, right-click unit01, choose New File and name it python_core.py.
Paste the script below and save with Ctrl+S (Windows, Linux) or Cmd+S (macOS).
"""The part of Python an AI engineer uses every day, on one sales order.
Run it: python python_core.py
"""
import json
# 1. Values and variables: a name that points at a value.
order_id = "9000001" # text (str)
amount = 18250.00 # a number with decimals (float)
is_blocked = True # True or False (bool)
print(type(order_id), type(amount), type(is_blocked))
# 2. f-strings: put values inside text.
print(f"Order {order_id} is worth {amount:,.2f} USD")
# 3. Dictionaries: named fields, like one record from an API.
order = {"SalesOrder": "9000001", "SoldToParty": "CUST-A", "DeliveryBlockReason": "01"}
print(order["SoldToParty"]) # read a field that must exist
print(order.get("HeaderBillingBlockReason")) # .get returns None if the field is missing
# 4. Lists: many values in order, like the records an API returns.
orders = [order, {"SalesOrder": "9000002", "SoldToParty": "CUST-B", "DeliveryBlockReason": ""}]
print(len(orders), "orders")
# 5. Loops and if: do something for each item, but only when a condition holds.
for o in orders:
if o["DeliveryBlockReason"]: # empty text counts as False
print("blocked:", o["SalesOrder"])
# 6. List comprehension: build a new list from an old one in one line.
blocked = [o["SalesOrder"] for o in orders if o["DeliveryBlockReason"]]
print(blocked)
# 7. Functions: name a piece of logic so you can reuse and test it.
def is_order_blocked(o: dict) -> bool:
return bool(o.get("DeliveryBlockReason") or o.get("HeaderBillingBlockReason"))
print([is_order_blocked(o) for o in orders])
# 8. JSON: the text format APIs send. json.loads turns text into Python; json.dumps goes back.
text = '{"d": {"results": [{"SalesOrder": "9000003", "TotalNetAmount": "5100.00"}]}}'
data = json.loads(text)
first = data["d"]["results"][0]
print(float(first["TotalNetAmount"]) * 2) # amounts often arrive as text: convert first
print(json.dumps(first, indent=2))
# 9. Errors: expect them and say what went wrong instead of crashing.
try:
print(first["DeliveryBlockReason"])
except KeyError as err:
print("field not in this record:", err)
Run it:
python python_core.py
On macOS or Linux, use python3 if python isn't found.
You should see exactly this:
<class 'str'> <class 'float'> <class 'bool'>
Order 9000001 is worth 18,250.00 USD
CUST-A
None
2 orders
blocked: 9000001
['9000001']
[True, False]
10200.0
{
"SalesOrder": "9000003",
"TotalNetAmount": "5100.00"
}
field not in this record: 'DeliveryBlockReason'
Match each output line to the numbered comment in the script. Then change something and run again: set "DeliveryBlockReason": "01" on the second order and watch lines 5 to 7 change.
Create another new file in unit01 named api_lab.py.
Paste the whole script below and save.
"""API lab: learn how Python talks to web APIs, using a small SAP-shaped practice API.
How to run (from the folder that holds this file):
python api_lab.py start a practice API on your own computer and run six experiments
python api_lab.py --sap run the same client against SAP's sandbox (needs SAP_API_KEY in .env)
python api_lab.py --llm also ask a language model to summarize the result (needs ANTHROPIC_API_KEY)
The practice API only exists while the script runs. Its data is made up.
"""
import json
import os
import sys
import threading
from collections import Counter
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlencode, urlparse
import requests
try: # read keys from a .env file if you set one up (see "Set up your computer")
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
PORT = 8765 # the local "door number" of the practice API
PRACTICE_KEY = "course-demo-key" # the practice API's key; it protects nothing real
SERVICE = "/sap/opu/odata/sap/API_SALES_ORDER_SRV"
SAP_SANDBOX = "https://sandbox.api.sap.com/s4hanacloud" + SERVICE
# Made-up sales orders, shaped like SAP's Sales Order API. Amounts are text, as in OData V2.
ORDERS = [
{"SalesOrder": "9000001", "SoldToParty": "CUST-A", "TotalNetAmount": "18250.00",
"TransactionCurrency": "USD", "DeliveryBlockReason": "01", "HeaderBillingBlockReason": ""},
{"SalesOrder": "9000002", "SoldToParty": "CUST-B", "TotalNetAmount": "940.00",
"TransactionCurrency": "USD", "DeliveryBlockReason": "", "HeaderBillingBlockReason": "02"},
{"SalesOrder": "9000003", "SoldToParty": "CUST-C", "TotalNetAmount": "5100.00",
"TransactionCurrency": "USD", "DeliveryBlockReason": "", "HeaderBillingBlockReason": ""},
{"SalesOrder": "9000004", "SoldToParty": "CUST-A", "TotalNetAmount": "7300.00",
"TransactionCurrency": "USD", "DeliveryBlockReason": "01", "HeaderBillingBlockReason": ""},
{"SalesOrder": "9000005", "SoldToParty": "CUST-D", "TotalNetAmount": "12000.00",
"TransactionCurrency": "USD", "DeliveryBlockReason": "", "HeaderBillingBlockReason": ""},
]
# ---------------------------------------------------------------- the practice API (server)
class PracticeAPI(BaseHTTPRequestHandler):
"""Answers GET requests the way a simple OData V2 service would."""
def do_GET(self):
url = urlparse(self.path)
query = {k: v[0] for k, v in parse_qs(url.query).items()}
if self.headers.get("APIKey") != PRACTICE_KEY:
return self.reply(401, {"error": {"message": "Missing or wrong API key"}})
if url.path != SERVICE + "/A_SalesOrder":
return self.reply(404, {"error": {"message": f"No such resource: {url.path}"}})
rows = ORDERS
if "$filter" in query: # supports one condition: Field eq 'value' or Field ne 'value'
try:
field, op, value = query["$filter"].split(" ", 2)
value = value.strip("'")
assert op in ("eq", "ne") and field in ORDERS[0]
except (ValueError, AssertionError):
return self.reply(400, {"error": {"message": "Filter must look like: Field eq 'value'"}})
rows = [r for r in rows if (r[field] == value) == (op == "eq")]
skip, top = int(query.get("$skip", 0)), int(query.get("$top", len(rows)))
rows = rows[skip:skip + top]
if "$select" in query:
fields = query["$select"].split(",")
rows = [{f: r[f] for f in fields if f in r} for r in rows]
self.reply(200, {"d": {"results": rows}})
def reply(self, status: int, body: dict) -> None:
data = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args): # keep the terminal quiet
pass
def start_practice_api() -> str:
"""Start the practice API in the background and return its address."""
try:
server = ThreadingHTTPServer(("127.0.0.1", PORT), PracticeAPI)
except OSError:
sys.exit(f"Port {PORT} is busy. Change PORT near the top of the script, save and run again.")
threading.Thread(target=server.serve_forever, daemon=True).start()
return f"http://127.0.0.1:{PORT}{SERVICE}"
# ---------------------------------------------------------------- the client (what you reuse)
def get_json(url: str, key: str | None, params: dict | None = None) -> tuple[int, dict]:
"""Send one GET request and return (status code, JSON body). Never hangs, never crashes."""
headers = {"Accept": "application/json"}
if key:
headers["APIKey"] = key
try:
query = urlencode(params or {}, quote_via=quote, safe="$,") # spaces become %20
resp = requests.get(url, headers=headers, params=query, timeout=30)
except requests.exceptions.Timeout:
return 0, {"error": "The server did not answer within 30 seconds."}
except requests.exceptions.ConnectionError:
return 0, {"error": f"Could not connect to {urlparse(url).netloc}. Network or proxy?"}
print(f" GET {resp.url}")
try:
body = resp.json()
except ValueError: # the answer was not JSON, for example an HTML error page
body = {"error": "Response was not JSON", "text": resp.text[:200]}
return resp.status_code, body
MEANING = { # what the common status codes mean, in plain words
200: "OK", 400: "Bad Request: the server didn't understand the question",
401: "Unauthorized: missing or wrong key", 403: "Forbidden: known key, but not allowed",
404: "Not Found: wrong address", 429: "Too Many Requests: slow down",
500: "Internal Server Error: the server failed", 503: "Service Unavailable: try later",
}
def meaning(status: int) -> str:
return f"{status} {MEANING.get(status, 'see the list of status codes')}"
def records(body: dict) -> list:
"""OData V2 puts the list of records under d -> results."""
return body.get("d", {}).get("results", [])
def summarize(orders: list) -> dict:
"""Turn raw records into the numbers a person cares about."""
blocked = [o for o in orders if o.get("DeliveryBlockReason") or o.get("HeaderBillingBlockReason")]
return {
"orders_read": len(orders),
"blocked": len(blocked),
"blocked_value": round(sum(float(o.get("TotalNetAmount") or 0) for o in blocked), 2),
"blocked_by_customer": dict(Counter(o.get("SoldToParty") for o in blocked)),
}
def ask_llm(summary: dict) -> str:
"""Ask a language model for a two-sentence summary. Replace this function for another provider."""
import anthropic # imported here so the rest runs without it
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
message = client.messages.create(
model=os.environ.get("LLM_MODEL", "claude-opus-5-5"),
max_tokens=300,
messages=[{"role": "user", "content":
"In two sentences for a credit manager, summarize these blocked sales order "
"figures. Use only these numbers; don't guess what block codes mean.\n"
+ json.dumps(summary)}],
)
return "".join(block.text for block in message.content if block.type == "text")
# ---------------------------------------------------------------- the experiments
def main() -> None:
args = sys.argv[1:]
if "--sap" in args:
base, key = SAP_SANDBOX, os.environ.get("SAP_API_KEY")
if not key:
sys.exit("SAP_API_KEY is not set. Add it to .env (see Set up your computer).")
print("Target: SAP Business Accelerator Hub sandbox\n")
else:
base, key = start_practice_api(), PRACTICE_KEY
print(f"Target: practice API running on your computer at {base}\n")
orders_url = base + "/A_SalesOrder"
print("1. Call without a key")
status, body = get_json(orders_url, None, {"$top": "2"})
if status == 0:
sys.exit(f" -> no answer: {body['error']}")
print(f" -> {meaning(status)}\n")
print("2. Call with the key")
status, body = get_json(orders_url, key, {"$top": "2", "$format": "json"})
print(f" -> {meaning(status)}, {len(records(body))} records. First one:")
print(" " + json.dumps(records(body)[:1], indent=2).replace("\n", "\n ") + "\n")
print("3. Ask for fewer fields with $select")
status, body = get_json(orders_url, key, {"$top": "3", "$select": "SalesOrder,SoldToParty",
"$format": "json"})
print(f" -> {meaning(status)}: {records(body)}\n")
print("4. Ask for fewer rows with $filter")
status, body = get_json(orders_url, key, {"$top": "50", "$filter": "DeliveryBlockReason ne ''",
"$format": "json"})
print(f" -> {meaning(status)}: {len(records(body))} orders with a delivery block\n")
print("5. Ask for something that doesn't exist")
status, body = get_json(base + "/A_SalesOrderTypo", key, {"$top": "1"})
print(f" -> {meaning(status)}\n")
print("6. Turn records into numbers")
status, body = get_json(orders_url, key, {"$top": "50", "$format": "json"})
if status != 200:
sys.exit(f" -> {meaning(status)}: {body}. Stopping here.")
summary = summarize(records(body))
print(" " + json.dumps(summary, indent=2).replace("\n", "\n "))
if "--llm" in args:
print("\n7. Ask a language model to put it in words")
print(" " + ask_llm(summary))
if __name__ == "__main__":
main()
You should see this (the practice data is fixed, so yours will match):
Target: practice API running on your computer at http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV
1. Call without a key
GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=2
-> 401 Unauthorized: missing or wrong key
2. Call with the key
GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=2&$format=json
-> 200 OK, 2 records. First one:
[
{
"SalesOrder": "9000001",
"SoldToParty": "CUST-A",
"TotalNetAmount": "18250.00",
"TransactionCurrency": "USD",
"DeliveryBlockReason": "01",
"HeaderBillingBlockReason": ""
}
]
3. Ask for fewer fields with $select
GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=3&$select=SalesOrder,SoldToParty&$format=json
-> 200 OK: [{'SalesOrder': '9000001', 'SoldToParty': 'CUST-A'}, {'SalesOrder': '9000002', 'SoldToParty': 'CUST-B'}, {'SalesOrder': '9000003', 'SoldToParty': 'CUST-C'}]
4. Ask for fewer rows with $filter
GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=50&$filter=DeliveryBlockReason%20ne%20%27%27&$format=json
-> 200 OK: 2 orders with a delivery block
5. Ask for something that doesn't exist
GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrderTypo?$top=1
-> 404 Not Found: wrong address
6. Turn records into numbers
GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=50&$format=json
{
"orders_read": 5,
"blocked": 3,
"blocked_value": 26490.0,
"blocked_by_customer": {
"CUST-A": 2,
"CUST-B": 1
}
}
Read the results in order. Each experiment teaches one thing:
Experiment
What you see
What it teaches
1. Without a key
401 Unauthorized
The server checks the APIKey header before anything else
2. With the key
200 OK and a record
The answer is JSON; OData V2 puts records under d, results
3. $select
Only two fields per record
Ask only for what you need
4. $filter
Only orders with a delivery block
Let the server do the filtering. Notice the space became %20 in the address
5. Wrong address
404 Not Found
A typo in the path is a different error from a wrong key
6. Summary
Counts and a total
Records become the few numbers a person or a model needs
When the script ends, the practice API stops too. It only exists while the script runs.
In api_lab.py, find experiment 4 and change the filter from "DeliveryBlockReason ne ''" to "SoldToParty eq 'CUST-A'". Change the printed text after it to orders for CUST-A. Save.
Run python api_lab.py again. Experiment 4 should now report 2 orders for CUST-A.
Now break it on purpose: change the filter to "SoldToParty equals 'CUST-A'" and run again. Experiment 4 now shows 400 Bad Request and 0 orders, because the practice API only understands eq and ne. That is what a malformed question looks like.
Put the filter back to "DeliveryBlockReason ne ''" and the text back to orders with a delivery block, and save.
#Step 6 (optional): Point the same client at SAP's sandbox
Check that .env in your course folder has a line SAP_API_KEY="..." with your key from api.sap.com (Show API Key on an API page). The script finds .env in the folder above unit01 on its own.
Run:
python api_lab.py --sap
You should see Target: SAP Business Accelerator Hub sandbox and the same six experiments, now with SAP's addresses and SAP's demo orders. Experiment 1 should show 401. Experiment 2 onwards should show 200 OK. The records have the same field names as the practice API, because the practice API copies the shape of SAP's.
Some results may differ, and that is valid, not an error: experiment 4 may find 0 orders with a delivery block if the demo data has none. Experiment 5 may return a different 4xx code, because each server words its errors in its own way. The sandbox is shared and its data changes.
#Step 7 (optional): Ask a model to put it in words
If you haven't yet, create a key in the Claude Console and add it to .env as ANTHROPIC_API_KEY="...", as described in the setup topic. Using another provider? Replace only the ask_llm function.
Run:
python api_lab.py --llm
Add --sap as well to summarize the sandbox data instead of the practice data.
After experiment 6 you get a seventh block with two sentences from the model. The wording varies from run to run. For the practice data it should mention 3 blocked orders worth 26,490 and that CUST-A has two of them.
Notice what the model receives: four numbers and a customer count, not the raw orders. Reducing data before a model call is a habit you will use in every later unit.
Settings: the practice door number and key, the service path, and SAP's sandbox address
ORDERS
Five made-up sales orders shaped like SAP's
PracticeAPI.do_GET
The practice server: checks the key (401), checks the address (404), applies $filter (400 if malformed), $skip, $top and $select, and answers in OData V2 shape
reply
Writes the status code, headers and JSON body of an answer
start_practice_api
Starts the practice server in the background, on your computer only
get_json
The client you reuse: builds the address, sends the key, always uses a timeout, and turns network failures into a clear message instead of a crash
MEANING, meaning
Plain-language names for common status codes
records
Takes the list of records out of the d, results wrapper
summarize
Counts blocked orders, adds up their value and groups them by customer
ask_llm
Only with --llm: sends the summary to a model and returns its text
main
Chooses the target (practice or --sap) and runs the experiments in order
One function does every call. Timeouts, headers and error handling live in get_json. When you add retries or logging later, you change one place.
Errors become messages, not crashes. A beginner's first SAP call often fails for network reasons. The script says which kind of failure it was.
The practice API mirrors SAP's shape. Code you write against it works against the sandbox with a new address and key. That is how FDEs prototype before they get access to a customer's system.
The model gets numbers, not records. Less data in the prompt means lower cost, less leakage and fewer ways for the model to go wrong.
SAP Business Accelerator Hub at api.sap.com is where SAP publishes APIs for its products, including S/4HANA. SAP Learning describes three things it gives a learner:
Documentation of each API: its entities, fields and parameters.
Sandbox APIs with test data, so you can call a real SAP API shape without owning a system.
Try Out, to call an API from the browser, and Show API Key, to get the sandbox key your code sends in the APIKey header.
Start every SAP integration here. The field names, entity sets and parameters you use in code should come from the API's page on the hub, not from memory or a blog post.
#Calling a real system: communication arrangements
A sandbox key doesn't open a company's S/4HANA Cloud system. SAP Learning's integration course describes how an administrator exposes an API there:
Every API belongs to one or more communication scenarios, which bundle the inbound and outbound pieces for an integration.
The administrator creates a communication user, a technical user for the calling system.
They create a communication system, representing your application.
They create and activate a communication arrangement for the scenario, linking the system and user.
SAP Learning names basic authentication (user name and password) and certificate-based authentication for these users. Other set-ups, such as calling through SAP BTP destinations, come up in later units.
SAP APIs come in OData V2 and OData V4 flavors, and the JSON looks different. V2 wraps records in d and results, as in this lab. V4 puts them in a value array, and the count option is $count instead of V2's $inlinecount. Check the version on the API's page before you write the code that reads the answer.
A model provider's API follows the same rules: an address, a key in a header, a JSON request, a status code and a JSON answer. The Anthropic Python SDK used in Step 7 hides the HTTP details: client.messages.create(...) sends one request and reads the key from ANTHROPIC_API_KEY. SAP's own route to models, the generative AI hub, is covered in Unit 5.
Timeouts on every call. A call without a timeout can hang your program. The Requests documentation says nearly all production code should set one.
Retries with care. Retry on 429, 503 and network errors, with a growing wait between tries. Don't retry on 400, 401, 403 or 404: the answer won't change. Never blindly retry a create request, or you may create the record twice.
Paging. Real systems hold more records than one call returns. Loop with $top and $skip until a page comes back short, and set an upper limit so a bug can't read a million records.
Least privilege. The communication user an AI tool uses should see only what the tool needs. Read-only unless changes are part of the design and approved.
Data minimization. Use $select. Strip or mask personal data before it reaches a prompt. Unit 11 covers this in depth.
Secrets. Keys in .env locally; a secrets store or SAP BTP service bindings in production. Never in code, logs or screenshots.
Logging. Log the address, status code and timing of each call, not the full response body, which may hold personal data.
Cost. Every model call costs money. Count model calls per business transaction before you scale.
Clean core. Use the APIs SAP publishes for integration rather than reading SAP tables directly. Published APIs survive upgrades; table layouts are not a contract.
Extend the API lab so its result can feed later work.
Copy api_lab.py to a new file blocked_summary.py in unit01.
At the end of main(), after the summary is printed, add these two lines (inside main, with the same indentation as the print above them):
with open("blocked_summary.json", "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
Run python blocked_summary.py. Check that blocked_summary.json appears in unit01 and holds the same numbers as experiment 6.
Add "blocked_value_by_customer" to summarize: a dictionary from customer to the total value of their blocked orders. Hint: start with an empty dictionary and add each blocked order's float(TotalNetAmount) to its customer's entry with .get(customer, 0).
If you have a sandbox key, run python blocked_summary.py --sap and compare the file with the practice result.
Save your work in Git from the course folder:
git add unit01/python_core.py unit01/api_lab.py unit01/blocked_summary.py
git commit -m "Add API lab and blocked-orders summary"
Done when:blocked_summary.json holds orders_read, blocked, blocked_value, blocked_by_customer and your new blocked_value_by_customer, and git log shows the commit. For the practice data, blocked_value_by_customer should be {"CUST-A": 25550.0, "CUST-B": 940.0}. Later in Unit 1 you call SAP's API in more depth, and this summary shape is the input your later triage and evaluation work starts from.
Pick one answer for each question. The explanation appears after you choose.
1Which part of an HTTP request carries the API key?
Answer: B. Method, host, path, query parameters, headers and, for creates and changes, a body. The key travels in a header; for the SAP sandbox that header is APIKey.
2Experiment 1 returns 401 and experiment 5 returns 404. What do you fix?
Answer: A. 401 Unauthorized means missing or wrong credentials: fix the key. 404 Not Found means a wrong address or a record that doesn't exist: fix the path. Different errors need different fixes, so never report them all as "API error".
3Why pass timeout on every call, and what should your program do when a call times out?
Answer: D. Without a timeout a call can hang your program indefinitely. On a timeout, report which call failed, and retry only with a growing wait if the call is safe to repeat.
4A colleague sends the model every field of 500 sales orders. Name two OData options that would help, and two reasons to use them.
Answer: C. $select for only the needed fields and $filter for only the relevant orders, such as blocked ones. They cut cost and tokens, and they keep data that isn't needed out of the prompt.
5What does resp.json()["d"]["results"] return for an OData V2 answer, and what trap waits in the amounts?
Answer: B. The list of records, each a Python dictionary. Amounts such as TotalNetAmount arrive as text, like "18250.00", so convert them before adding up; use decimal for money in production.
6Which errors should your code retry?
Answer: A. Retry 429, 503 and network errors with a growing wait. Don't retry 400, 401, 403 or 404, because the answer won't change. Never blindly retry a create request, or you may create the record twice.
7What must an administrator set up before your code can call a company's S/4HANA Cloud system rather than the sandbox?
Answer: D. A communication user, a communication system for your application, and an active communication arrangement for the API's communication scenario. Your code then sends those credentials instead of the sandbox APIKey.
8You switch from an OData V2 API to a V4 API and your code fails with KeyError: 'd'. Why?
Answer: C. V2 wraps records in d and results; V4 puts them in a value array. Check the OData version on the API's hub page before writing the code that reads the answer.
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.