Klicke hier um ein unverbindliches Erstgespräch buchen!

Posted by Markus Wals

weclappy 1.0: Der Python-Client, der die weclapp-API wirklich versteht

API Open Source Python weclapp weclappy

weclappy 1.0: Der Python-Client, der die weclapp-API wirklich versteht

Seit heute liegt weclappy 1.0.1 auf PyPI. Es ist das erste stabile Release unseres Python-Clients für die weclapp REST API, und es ist mehr als eine Versionsnummer: weclappy implementiert den kompletten Load-Management-Vertrag von weclapp, wiederholt keinen Write, für den es nicht bürgen kann, und macht aus den Eigenheiten der API v2 (Zusatzfelder, referencedEntities, additionalProperties, Paginierung) ganz normale Python-Objekte.

Dieser Artikel ist bewusst lang. Er soll das Nachschlagewerk sein, das wir uns selbst gewünscht hätten, als wir die ersten weclapp-Integrationen in Python gebaut haben. Du findest hier die Kurzfassung der Doku, Praxisbeispiele aus unserem Alltag bei wals.pro und Rechenbeispiele, die zeigen, warum die einzelnen Entscheidungen Sinn ergeben.

weclappy ist unabhängig und community-maintained, kein Produkt von weclapp. Wir bei wals.pro bauen Fulfillment-Anbindungen, Integrationen und KI-Werkzeuge rund um weclapp. Die Library ist das Stück Infrastruktur, auf dem das alles läuft.

Fünf Zeilen, die alles zeigen

from weclappy import Weclapp

client = Weclapp.for_tenant("acme", api_key)
orders = client.get_all(
    "salesOrder",
    {"status-eq": "ORDER_CONFIRMED", "includeReferencedEntities": "customerId"},
)
print(orders[0].customer.name)  # customerId wird zur referenzierten Partei aufgelöst

Hinter diesen fünf Zeilen passiert: sort=id wird gesetzt, Seite 1 wird gelesen, bei Bedarf /count abgefragt, die restlichen Seiten laufen parallel unter einem adaptiven Controller, Duplikate und Lücken werden erkannt, und orders[0].customer findet sich über referencedEntities selbst. Du schreibst nur die Geschäftslogik.

Warum Python, und warum kein eigener Client mehr

Bevor wir in die Technik gehen, ein Wort zu den Werten hinter weclappy, denn sie erklären die meisten Designentscheidungen.

Python ist schnell gebaut und skaliert trotzdem. Ein Skript, das heute Nachmittag Artikelpreise abgleicht, kann nächste Woche eine FastAPI-Anwendung mit Webhook-Endpunkten sein, ohne die Sprache oder den Client zu wechseln. Wir betreiben weclappy in Cloud Run, in Containern und in schlichten Cron-Jobs. Ein pip install, ein Container, fertig. Kein Build-Schritt, kein generierter Code, kein SDK-Generator, dessen Output du nach jedem API-Update neu prüfen musst.

Leichtgewichtig und dokumentiert. Zwei Laufzeitabhängigkeiten (requests, urllib3), Python 3.12+, py.typed, mypy strict, 97 % Testabdeckung inklusive eines In-Process-Fake-Servers, der Queueing, 429 und abgebrochene Verbindungen simuliert. Die README ist die Doku, und sie beschreibt jede Entscheidung mit Begründung.

Gebaut für die Zusammenarbeit mit KI. Nach unserer Erfahrung schreiben KI-Assistenten in Python den verlässlichsten Integrationscode, und ein typisierter, vollständig dokumentierter Client macht daraus einen echten Hebel: Du gibst dem Assistenten die README mit, und er nutzt get_all, iter_keyset oder to_payload() korrekt, statt sich eigene Paginierung auszudenken. Wir erleben das täglich, weil unser weclapp-MCP-Server auf genau dieser Library sitzt.

Die weclapp-API wird vollständig und sauber unterstützt. Offizielle Endpunkte, Zusatzfelder in allen elf Werttypen, referencedEntities, additionalProperties, Dateiupload und Download, Methodenaufrufe wie createSalesInvoice, dazu die inoffiziellen Endpunkte klar gekennzeichnet mit Fallback.

Und daraus folgt der eigentliche Punkt: Es lohnt sich nicht mehr, selbst einen Client zu bauen. Nicht, weil es schwer wäre, einen HTTP-Request an weclapp zu schicken. Sondern weil ein Client, der im Produktivbetrieb standhält, eine lange Liste an Verhalten braucht, die man erst nach dem dritten Vorfall versteht.

Zehn Fähigkeiten, die ein produktionsreifer weclapp-Client braucht, von Queue-Headern über Retry-Budgets und Write-Absicherung bis zu typisierten Fehlern, alle in weclappy 1.0 enthalten.

Wie weclapp Last steuert, und was das für deinen Code heißt

Das Wichtigste zuerst, weil es die meisten Integrationsprobleme erklärt: weclapp hat kein Rate-Limit pro Minute. weclapp begrenzt die Anzahl gleichzeitig aktiver Requests pro Mandant. Die Zahl ist nicht veröffentlicht und kann je Mandant und über die Zeit variieren. Requests oberhalb dieser Grenze landen in einer Queue, aktuell bis zu etwa 30 Sekunden, danach antwortet weclapp mit HTTP 429.

Last wird als Request-Zeit abgerechnet, nicht als Request-Anzahl. Wenige große Requests sind günstiger als viele kleine.

Jede Antwort auf einen Request, der in der Queue gewartet hat, trägt zwei Header:

Header Bedeutung
X-Weclapp-Wait-Ms Zeit, die der Request in weclapps Queue verbracht hat. Ein Bericht, keine Anweisung.
X-Weclapp-Wait-Reason concurrency (zu viele Requests aktiv), load (Gesamtlast zu hoch) oder beides. Erscheint auf 2xx und 429.

Und zwei Header kannst du selbst setzen:

Header Bedeutung
X-Weclapp-Wait-Timeout-Ms Längste Queue-Wartezeit, die du akzeptierst. Kann den Server-Default nur senken.
X-Weclapp-Request-Timeout-Ms Längste serverseitige Verarbeitungszeit. Darüber liefert weclapp ein 400 request_timeout.

weclapp bittet Clients ausdrücklich darum, proaktiv zu drosseln, sobald Wartezeiten steigen, statt auf 429 zu warten, nach 429 exponentiell zurückzugehen und nur sichere Methoden automatisch zu wiederholen. Genau das tut weclappy.

Rechenbeispiel: Warum „einfach parallel" nach hinten losgeht

Nimm einen Mandanten mit einer (angenommenen, unveröffentlichten) Grenze von 5 gleichzeitigen Requests und Requests, die je 2 Sekunden Verarbeitung brauchen.

Dein Client schickt gleichzeitig Letzter Request wartet in der Queue Ergebnis
5 0 s alles sofort
25 4 Wellen × 2 s = 8 s geht durch, aber jede Antwort meldet concurrency
100 19 Wellen × 2 s = 38 s ab etwa 30 s: HTTP 429 für den Rest

Ein naiver Thread-Pool mit 100 Workern ist also nicht schneller als einer mit 5, er produziert nur Queue-Zeit und 429-Antworten. Und das Schlimmste: Jeder 429 nach einer vollen Queue hat den Mandanten bereits 30 Sekunden belegt. Die Grenze kennst du nicht, sie ändert sich, und sie wird von allen Integrationen des Mandanten geteilt. Der einzige stabile Weg ist, die Header zu lesen und die eigene Parallelität daran auszurichten.

Adaptive Parallelität: der AIMD-Controller

weclappy hält pro Client (oder pro Mandant, wenn du den Controller teilst) ein Target: die Anzahl paralleler Reads, die gerade erlaubt sind. Jeder Read holt sich vor dem Senden ein Permit und gibt es mit der Antwort zurück. Das Target wird einmal pro Epoche angepasst, wobei eine Epoche target abgeschlossene Antworten lang ist.

Ereignis Wirkung auf das Target
Start 2
Obergrenze 10 (Weclapp(max_concurrency=...))
Wait-Reason concurrency oder Wartezeit ≥ 250 ms −1 (höchstens eine Senkung pro Epoche)
Wait-Reason load oder Wartezeit ≥ 2.000 ms halbiert, aufgerundet (höchstens eine Senkung pro Epoche)
HTTP 429 1, plus gemeinsamer Cooldown von mindestens 2 s, in dem kein Request rausgeht
5xx oder Transportfehler keine Änderung, aber kein Wachstum in dieser Epoche
Volle Epoche, Fenster ausgelastet, keine Senkung, kein Fehler +1

Stufenlinie der Ziel-Parallelität über abgeschlossene Antworten: Wachstum von 2 auf 10, Senkung um eins bei vier gleichzeitigen concurrency-Hinweisen, Halbierung bei load, Absturz auf 1 bei HTTP 429, danach Erholung. Erzeugt mit dem echten Controller der Library.

Zwei Details in dieser Grafik sind der Unterschied zwischen einem Controller, der im Alltag funktioniert, und einem, der nervt:

Höchstens eine Senkung pro Epoche. Antworten kommen in Bursts. Wenn vier gleichzeitig laufende Reads alle load melden, beschreiben sie denselben Moment. Ein Controller, der pro Antwort halbiert, würde in einer einzigen Runde von 10 auf 1 fallen (10 → 5 → 3 → 2 → 1). weclappy reagiert einmal, beobachtet die Wirkung, und reagiert dann erneut, falls nötig. In der Grafik siehst du das bei „4× Wait-Reason: concurrency → nur −1".

Wachstum nur bei Sättigung. Das Target wächst nur, wenn der Caller die Slots tatsächlich genutzt hat. Ein sequenzieller Aufrufer treibt das Target nicht hoch, und nach einem 429 erholt sich der Controller wieder, statt für immer bei 1 zu bleiben (ein Fehler, den unser interner 0.7.0-Branch hatte und der nie veröffentlicht wurde).

Writes halten kein Permit, warten aber auf einen aktiven 429-Cooldown. Ein Request in eine Queue, die gerade 429 geantwortet hat, bekommt nur den nächsten 429.

Einen Controller pro Mandant teilen

Die Grenze gilt pro Mandant, nicht pro Prozess. Wenn mehrere Clients (etwa ein Webhook-Handler und ein Sync-Job im selben Prozess) denselben Mandanten bedienen, teilen sie sich einen Controller:

from weclappy import ConcurrencyController, ConcurrencySettings, Weclapp

shared = ConcurrencyController(ConcurrencySettings(max_concurrency=8))

sync = Weclapp.for_tenant("acme", api_key, concurrency=shared)
webhooks = Weclapp.for_tenant("acme", api_key, concurrency=shared)

Das zahlt sich doppelt aus: Die Clients konkurrieren nicht gegeneinander um die Queue, und ein warmer Controller startet nicht jedes Mal wieder bei 2 (siehe Rechenbeispiel im nächsten Abschnitt).

Paginierung, der du vertrauen kannst

get_all ist die Methode, die du am häufigsten nutzt, und sie macht in 1.0 mit dem Default threaded="auto" Folgendes:

  1. Wenn params weder sort noch orderBy enthält, setzt weclappy sort=id. Offset-Seiten ohne stabile Reihenfolge verschieben Zeilen über Seitengrenzen.
  2. Seite 1 wird sequenziell gelesen.
  3. Ist Seite 1 kurz, ist der Read fertig: kein Count, kein Thread-Pool, genau ein Request. Die meisten kleinen Reads kosten exakt einen Request.
  4. Sonst fragt weclappy GET {entity}/count mit dem Filterteil der Parameter. Übersteigt der Count max_records, bricht der Read ab, bevor eine weitere Seite gelesen wird.
  5. Die Seiten 2 bis N laufen parallel unter dem Controller, höchstens min(max_workers, target) gleichzeitig.
  6. Die Seiten werden in Seitenreihenfolge zusammengeführt. Eine doppelte Id über Seiten hinweg oder weniger Zeilen als gezählt wirft WeclappPaginationError: Der Datenbestand hat sich während des Reads geändert, du solltest den Read wiederholen.

Rechenbeispiel: 50.000 Aufträge

Balkendiagramm der Modellrechnung für 50.000 Aufträge: sequenziell 40,0 Sekunden, weclappy 1.0 mit kaltem Controller 8,3 Sekunden, mit geteiltem warmem Controller 5,1 Sekunden. Annahmen: 50 Seiten à 1.000 Zeilen, 0,8 Sekunden pro Seite, 0,3 Sekunden für count.

Das ist eine Modellrechnung, keine Messung, aber sie rechnet exakt mit den Regeln der Library. Annahmen: 50 Seiten à 1.000 Zeilen, 0,8 s pro Seite, 0,3 s für /count, keine Queue-Wartezeit.

Variante Rechnung Dauer
weclappy 0.6, sequenziell 50 × 0,8 s 40,0 s
weclappy 1.0, kalter Controller Seite 1 (0,8 s) + Count (0,3 s) + Wellen mit 2, 3, 4, … 10 parallelen Seiten (9 × 0,8 s) 8,3 s
weclappy 1.0, warmer geteilter Controller Seite 1 (0,8 s) + Count (0,3 s) + 49 Seiten in 5 Wellen zu 10 (5 × 0,8 s) 5,1 s

Drei Dinge fallen auf. Erstens: Der Faktor zwischen sequenziell und parallel liegt bei knapp fünf bis acht, nicht bei zehn, obwohl die Obergrenze 10 ist. Der Controller tastet sich hoch, und das ist Absicht: Er weiß nicht, welche Grenze der Mandant gerade hat. Zweitens: Ein geteilter, bereits warmer Controller spart nochmals über drei Sekunden, weil die Anlaufphase entfällt. Drittens: Alle drei Varianten erzeugen dieselbe Request-Zeit auf dem Server. Parallelität verkürzt deine Wartezeit, sie senkt nicht die Last. Dafür brauchst du den nächsten Abschnitt.

Rechenbeispiel: 3.721 Artikel, drei Zugriffsmuster

Drei Kennzahlkacheln für 3.721 Artikel: 3.721 Requests mit einem GET pro Artikel, 8 Requests mit get_by_ids in Chunks à 500 Ids, 5 Requests und 0,6 Sekunden mit get_all auf der weclapp-Sandbox.

Die Zahl auf der rechten Kachel ist gemessen: 3.721 Artikel in 0,6 s mit 5 Requests auf der weclapp-Sandbox (API v2, Oktober 2026). Vier Seiten à 1.000 plus ein /count, parallel gelesen.

Die linke Kachel ist das Muster, das wir in handgeschriebenen Clients am häufigsten sehen: eine Id-Liste aus einem anderen System, dann ein GET article/id/{id} pro Id. 3.721 Requests. Jeder einzelne belegt einen Slot in der Mandanten-Queue, jeder einzelne zählt als Request-Zeit.

Die Mitte ist get_by_ids: Es liest die Zeilen mit id-in=[...] in parallelen Chunks. Die Chunk-Größe kommt aus Sandbox-Messungen: Die Edge vor weclapp (Akamai) lehnt URLs ab etwa 8,9 KB mit HTTP 400 ab, 640 numerische Ids passen darunter, 500 lassen Reserve für längere Ids und weitere Parameter. 3.721 ÷ 500 ergibt 8 Chunks.

rows = client.get_by_ids("article", ids, {"properties": "id,articleNumber,name"})

Ein Detail, das dich sonst einen Nachmittag kostet: id-in wird paginiert wie jede andere Liste. Ohne explizites pageSize bekommst du nur die Default-Seitengröße an Treffern zurück. weclappy schickt pro Chunk pageSize=len(chunk).

Für große Projektionen (viele Felder, Positionen, Referenzen) gibt es strategy="ids", das Muster, das weclapp selbst empfiehlt: erst nur die Ids mit allen Filtern lesen, dann die Zeilen mit der vollen Projektion über get_by_ids.

orders = client.get_all(
    "salesOrder",
    {
        "status-eq": "ORDER_CONFIRMED",
        "properties": "id,orderNumber,customerId,orderItems",
        "includeReferencedEntities": "customerId",
    },
    strategy="ids",
)

Exporte, die keine Zeile verlieren: iter_keyset

Offset-Paginierung ist kein Snapshot. Wird während eines langen Exports ein Datensatz angelegt oder gelöscht, verschieben sich die Seitengrenzen. weclappy erkennt Duplikate und Lücken, repariert sie aber nicht. Für Exporte und Reports gibt es deshalb Keyset-Paginierung mit sort=id und id-gt=<letzte Id>:

for party in client.iter_keyset("party", {"properties": "id,company", "partyType-eq": "CUSTOMER"}):
    export(party)

Keyset-Iteration ist immun gegen Inserts und Deletes während des Reads, und mit start_after="<id>" setzt du einen abgebrochenen Export genau dort fort, wo er stand. Das ist der Iterator für den nächtlichen Abgleich ins Data Warehouse.

max_records als Notbremse

client.get_all("article", {"active-eq": "true"}, max_records=50_000)

Ein Filter, der durch einen Tippfehler plötzlich alles matcht, wird vor dem ersten parallelen Request gestoppt, nicht nach einer Minute Volllast auf dem Mandanten.

Retries, die zur API passen, und Writes, die nie doppelt laufen

In 0.6.0 hing ein urllib3-Retry-Adapter am Client, der POST, PUT und DELETE bei 500, 502, 503, 504 und 429 wiederholte. Das klingt harmlos und ist es nicht: Ein 5xx oder ein 429 nach Queue-Wartezeit beweist nicht, dass der Write nicht verarbeitet wurde. weclapp kann die Rechnung längst gebucht haben, bevor die Antwort verloren ging. Ein wiederholtes POST salesInvoice erzeugt dann eine zweite Rechnung, eine wiederholte Lagerbuchung bucht doppelt.

In 1.0 gibt es genau eine Regel: Ein Write wird nur wiederholt, wenn die Verbindung nachweislich nie zustande kam.

Entscheidungsbaum nach einem fehlgeschlagenen Write: Verbindung nie zustande gekommen führt zum Retry aus dem Transient-Budget; 5xx, 429, Read-Timeout oder abgebrochene Verbindung führen zu WeclappTransportError mit outcome_unknown und einem Rücklese-Plan; 4xx-Fehler werden typisiert geworfen.

Bedingung Reads Writes
Verbindung nie zustande gekommen (DNS, refused, connect timeout) Retry, Transient-Budget Retry, Transient-Budget
Ausgang unbekannt (Read-Timeout, Verbindung nach dem Senden weg) Retry nie; WeclappTransportError mit outcome_unknown=True
500, 502, 503, 504 Retry, Transient-Budget nie
429 Retry, Rate-Limit-Budget, startet den Cooldown nie (wartet aber auf den Cooldown)
400 request_timeout, 409 persistence Retry, Problem-Budget nie
3xx nie gefolgt; WeclappRedirectError nie gefolgt

Das Rezept für den unbekannten Ausgang: Lies den Datensatz über einen Business-Key zurück, den du kontrollierst (Kundennummer, externe Referenz, ein Marker im Notizfeld).

import time
from weclappy import WeclappTransportError

def create_party_once(client, customer_number):
    try:
        return client.post(
            "party",
            {"partyType": "ORGANIZATION", "company": "Acme", "customerNumber": customer_number},
        )
    except WeclappTransportError as exc:
        if not exc.outcome_unknown:
            raise  # nachweislich nie gesendet, später gefahrlos wiederholbar
        for delay in (0, 2, 5, 10, 20, 30):
            time.sleep(delay)
            found = client.get("party", params={"customerNumber-eq": customer_number})
            if found:
                return found[0]  # der Write ist durchgegangen
        raise  # immer noch unbekannt: eskalieren statt erneut schreiben

Zwei Budgets, ein Deckel

Liniendiagramm der Wartezeiten vor Wiederholungsversuchen: Rate-Limit-Budget 2, 4, 8, 16, 32 Sekunden; Transient-Budget 0,3, 0,6, 1,2 Sekunden; gestrichelte Deckellinie bei 60 Sekunden. Daneben eine Kachel: null Retries für POST, PUT, DELETE nach dem Senden.

Budget Default Wartezeit vor Versuch n (0-basiert)
Transient (5xx, Netz) 3 0,3 · 2ⁿ s plus bis zu 0,3 s Jitter
Rate-Limit (429) 5 2 · 2ⁿ s plus bis zu 2 s Jitter
Problem (request_timeout, persistence) 1 0,3 · 2ⁿ s plus Jitter

Jede Wartezeit, auch ein Retry-After vom Server, ist bei max_backoff (60 s) gedeckelt. Der Grund ist ein echter Vorfall aus der Entwicklung: Ein Retry-After: 3600 hat alle Reads eine Stunde blockiert, ein unendlicher Wert hat jeden späteren acquire() mit OverflowError abstürzen lassen. Beides ist in 1.0 geschlossen. Jeder logische Request hat eigene Zähler, ein 429 verbraucht also nicht das Transient-Budget.

Timeouts mit Abstand

Einstellung Default Zweck
Client-Timeout 120 s requests-Timeout pro Versuch, begrenzt auch das Warten auf ein Permit
X-Weclapp-Wait-Timeout-Ms 30.000 längste akzeptierte Queue-Wartezeit
X-Weclapp-Request-Timeout-Ms 110.000 bewusst unter dem Client-Timeout

Die 10 Sekunden Abstand sind der Punkt: Ohne sie geben Client und Server im selben Moment auf, und ein Write endet in einem mehrdeutigen Read-Timeout statt in einem definitiven 400 request_timeout. Setzt du pro Request ein kürzeres Timeout, senkt weclappy den Request-Timeout-Header automatisch auf 90 % der Read-Zeit, damit ein ungeduldiger Client keine langlaufenden Ausführungen auf dem Server zurücklässt.

Entities: Zusatzfelder und Referenzen wie normale Felder

Die API v2 liefert Zusatzfelder als Array aus attributeDefinitionId plus typisiertem Wert, Referenzen als separate referencedEntities-Sektion und berechnete Werte als additionalProperties neben dem Ergebnis. Alles korrekt, alles mühsam. weclappy legt das flach auf ein WeclappEntity, eine dict-Unterklasse mit Attributzugriff.

Links die Rohantwort der weclapp API v2 mit customAttributes-Array, referencedEntities und additionalProperties; rechts dieselben Daten als flache Attribute auf dem WeclappEntity: order.lieferfenster, order.customer.company, order.totalGross, dazu der Rückweg über client.put.

  • Felder als Attribute: order.orderNumber ist order["orderNumber"]. Dict-Code läuft weiter.
  • Zusatzfelder flach: Jedes customAttributes-Element erscheint als Feld unter seinem attributeKey. Die Definitionen lädt weclappy einmal pro Client, lazy, unter einem Lock, bei der ersten Zeile, die sie braucht. Alle elf Werttypen gehen hin und zurück.
  • Referenzen aufgelöst: order.customer löst order.customerId gegen referencedEntities auf, auch wenn der Bucket anders heißt (customerId → party). Verschachtelt funktioniert das genauso: order.orderItems[0].article.articleNumber.
  • additionalProperties gemerged: Pro Zeile als Feld, entity.additional_properties listet sie.
articles = client.get(
    "article",
    params={
        "properties": "id,articleNumber,unitId,unit:id,unit:name",
        "additionalProperties": "currentSalesPrice",
        "includeReferencedEntities": "unitId",
        "pageSize": 5,
    },
)
for article in articles:
    print(article.articleNumber, article.currentSalesPrice, article.unit.name)

Und der Rückweg ist genauso kurz. to_payload() ist der einzige unterstützte Weg in einen Write: Es verwirft gemergte additionalProperties, baut customAttributes wieder auf, lässt aufgelöste Referenzobjekte weg und konvertiert verschachtelte Entities rekursiv. Die Write-Methoden rufen es für dich auf.

article = client.get("article", "4384", {"properties": "id,version,customAttributes"})
article.lieferfenster = "KW 43"   # ein bestehendes, schreibbares Zusatzfeld
client.put("article", article.id, article)

Zwei Sicherheitsregeln, die wir bewusst eingebaut haben: Als readOnly definierte Zusatzfelder werfen lokal, bevor ein Request rausgeht. Und die flache Schnittstelle fügt nie ein Zusatzfeld hinzu, das im Entity fehlt. Neue Attribute schickst du explizit als v2-customAttributes-Element. So kann ein Tippfehler im Attributnamen keine stille Falschbuchung erzeugen.

Optimistic Locking in vier Zeilen

from weclappy import WeclappOptimisticLockError

for _attempt in range(3):
    order = client.get("salesOrder", "4384", {"properties": "id,version,commission"})
    try:
        client.put("salesOrder", order.id, {"version": order.version, "commission": "B-17"})
        break
    except WeclappOptimisticLockError:
        continue  # neu lesen, Änderung erneut anwenden

put schickt standardmäßig ignoreMissingProperties=true, ein Teil-Payload ändert also nur die enthaltenen Felder. Für Vollersetzung gibst du {"ignoreMissingProperties": False} mit.

Die versteckten Endpunkte, klar beschriftet

weclapp führt in seinem versteckten OpenAPI-Dokument Endpunkte, die im Alltag enorm helfen, aber keinerlei Kompatibilitätsversprechen tragen. weclappy bietet sie an, markiert sie als inoffiziell und empfiehlt für jeden einen Fallback.

Methode Endpunkt Wofür
query() POST {entity}/query Filterausdruck im Body, also kein URL-Limit für lange id in [...]-Listen
query_count() POST {entity}/count Count mit Body-Filter
batch_query() POST batch/query bis zu 500 relative GET-Queries in einem Aufruf
openapi(include_hidden=True) GET meta/openapi.yaml das OpenAPI-Dokument deines Mandanten inklusive versteckter Endpunkte
from weclappy import WeclappAPIError

try:
    rows = client.query("article", filter=f"id in [{','.join(ids)}]", properties=["id", "name"], page_size=len(ids))
except WeclappAPIError:
    rows = client.get_by_ids("article", ids, {"properties": "id,name"})  # offizieller Fallback

Was wir auf der Sandbox gemessen haben: batch/query akzeptiert höchstens 500 Requests pro Aufruf, die Antwort kommt nicht in Request-Reihenfolge (weclappy sortiert nach Index), und /id/{id}-Pfade werden innerhalb eines Batches abgelehnt. POST {entity}/query sortiert mit orderBy, nicht mit sort. Alle vier zählen als Reads: Sie halten ein Permit und werden wie GET wiederholt.

Observability, ohne Secrets im Log

Jeder physische Versuch, auch ein fehlgeschlagener, ruft deinen on_response-Hook mit einem RequestMetrics-Objekt: Methode, Pfad ohne Query-String, Status, Dauer, Queue-Wartezeit, Wait-Reason, Correlation-Id, Versuchsnummer, Retry-Entscheidung, aktuelles Controller-Target.

from weclappy import RequestMetrics, Weclapp

def record(metrics: RequestMetrics) -> None:
    if metrics.wait_ms:
        print(f"{metrics.method} {metrics.path} wartete {metrics.wait_ms:.0f} ms ({metrics.wait_reason})")

client = Weclapp.for_tenant("acme", api_key, on_response=record)

client.stats aggregiert alles, inklusive request_seconds, der geschätzten serverseitigen Verarbeitungszeit, also genau der Größe, in der weclapp Last misst. Damit siehst du, welcher Job deinen Mandanten wirklich belastet. Logs und Metriken enthalten nie den API-Key, nie Query-Strings, nie Bodies.

Erweiterungspunkte statt Monkeypatching

import requests
from weclappy import OutgoingRequest, RetryPolicy, Weclapp

def tag(request: OutgoingRequest) -> None:
    request.headers["X-Correlation-ID"] = f"sync-job-{request.attempt}"

client = Weclapp.for_tenant(
    "acme",
    api_key,
    session=requests.Session(),   # Proxies, Zertifikate, eigene Adapter
    before_request=tag,
    retry_policy=RetryPolicy(max_retries=2, rate_limit_retries=8, max_backoff=30.0),
)

Dazu request() als öffentliche Ausweichroute für Endpunkte ohne Helfer, mit derselben Lastkontrolle, denselben Retries und demselben Parsing wie jede andere Methode. Die privaten Methoden _send_request und _check_response aus 0.x sind weg, und das ist gut so: Alles, wofür man sie gebraucht hat, geht jetzt über öffentliche, versionierte Schnittstellen.

Sicherheitsleitplanken, die du nicht abschalten musst

  • Redirects werden nie gefolgt. Ein 3xx wirft WeclappRedirectError. Ein 307/308 kann keinen Write-Body wiederholen, und der API-Key kann keinem Redirect auf einen fremden Host folgen.
  • Same-Origin-Guard. Endpunkte sind Pfade relativ zur base_url. Absolute URLs, fremde Hosts, ..-Segmente und Fragmente werfen ValueError.
  • Pfadsegmente werden encodiert. Eine Id aus einem Webhook-Payload mit /, ? oder # kann keinen anderen Endpunkt erreichen.
  • Typisierte Fehler. WeclappNotFoundError, WeclappValidationError, WeclappOptimisticLockError, WeclappRateLimitError, WeclappPaginationError und mehr, alle unter WeclappAPIError mit dem geparsten Problem-Dokument. Ein except WeclappAPIError fängt weiterhin alles.

Überall hosten, ohne Theater

Weil weclappy nur requests und urllib3 braucht und thread-sicher ist, läuft es dort, wo Python läuft:

  • Cloud Run oder ein Container irgendwo: ein Client pro Prozess, geteilt von allen Threads, als Context Manager geschlossen.
  • Cloud Functions, Firebase Functions, Lambda, Azure Functions: ein Client auf Modulebene, damit der Connection-Pool und der warme Controller über Aufrufe hinweg erhalten bleiben.
  • Webanwendungen mit FastAPI, Flask oder Django: ein Client als Anwendungsressource, die Webhook-Handler und Hintergrundjobs teilen.
  • Cron und Skripte: pip install weclappy, API-Key aus der Umgebung, fertig.
import os
from weclappy import Weclapp

client = Weclapp.for_tenant(os.environ["WECLAPP_TENANT"], os.environ["WECLAPP_API_KEY"])

def handler(event, context):
    open_orders = client.count("salesOrder", {"status-eq": "ORDER_CONFIRMED"})
    return {"openOrders": open_orders}

Halte den API-Key immer in der Umgebung oder im Secret Manager deiner Plattform. weclappy sendet ihn als AuthenticationToken und hält ihn aus allen Logs heraus, aber ein exc.response.request.headers enthält ihn weiterhin. Serialisiere keine rohen requests-Objekte in Error-Reporter.

Migration von 0.x

1.0 bricht die API bewusst, einmal und sauber. Die vollständige Liste steht in der README, die wichtigsten Punkte:

  • Python ≥ 3.12. 3.9 bis 3.11 werden nicht mehr unterstützt.
  • Paket statt Modul. Importiere öffentliche Namen nur aus weclappy.
  • Keyword-only. Jedes Konstruktor-Argument nach api_key, in get_all alles nach params.
  • Umbenennungen. id → entity_id, endpoint → entity. Positionen sind unverändert, id= funktioniert in 1.x noch mit DeprecationWarning.
  • get_all ist jetzt parallel (threaded="auto"). Für strikt sequenzielle Reads threaded=False.
  • Writes werden nie wiederholt. Behandle WeclappTransportError.outcome_unknown mit einem Rücklesen.
  • Zusätzliche Requests. Ein großes get_all schickt einen /count, die erste Zeile mit Zusatzfeldern lädt customAttributeDefinition einmal pro Client. Test-Doubles müssen beides beantworten.

Pinne in Anwendungen weclappy>=1.0,<2. Die öffentliche API ist ab jetzt unter Semantic Versioning eingefroren, Deprecations laufen mindestens ein Minor-Release vor der Entfernung, unterstützt werden die drei neuesten CPython-Versionen.

Was als Nächstes kommt

Auf der Roadmap stehen Bulk-Writes mit derselben Ausgangs-Sicherheit wie Einzel-Writes für 1.1 und eine asynchrone Variante für Version 2. Beides sind Ausblicke, keine Zusagen mit Datum.

Loslegen

python -m pip install weclappy
export WECLAPP_API_KEY="dein-api-key"

Wenn du eine weclapp-Integration planst oder eine bestehende stabilisieren willst, die regelmäßig in 429s läuft oder doppelte Belege erzeugt: Lass uns 30 Minuten darüber reden. Wir zeigen dir anhand deiner Jobs, welche Parallelität dein Mandant verträgt, und wo weclappy dir Code abnimmt.

Lizenz: MIT. weclappy ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zu weclapp.