# Part 6: Error Handling and SOAP Faults

## Why SOAP Error Handling Deserves Its Own Article

Most REST API tutorials cover error handling in a few paragraphs: return the right HTTP status code and a JSON body with a message. SOAP errors are more formal, more structured, and — if you do not understand them — more confusing.

When I first worked with a SOAP payment gateway, my client code crashed with `zeep.exceptions.Fault: Server` and no further context. The SOAP Fault had a detailed `<detail>` element with a typed error code and description, but I was not extracting it. Once I understood how SOAP Faults are structured and how zeep surfaces them, I could build proper error handling and give meaningful feedback in my application.

This part covers SOAP Fault structure, the differences between SOAP 1.1 and 1.2 faults, how to raise typed faults in spyne, and how to handle them defensively in zeep clients.

## SOAP Fault Structure

A SOAP Fault is a standardised way of communicating errors from a service to a client. When a fault occurs, the SOAP body contains a `<soap:Fault>` element *instead of* the normal response.

### SOAP 1.1 Fault

```xml
<soap:Body>
    <soap:Fault>
        <faultcode>soap:Server</faultcode>
        <faultstring>Product not found: SKU-999</faultstring>
        <faultactor>http://example.com/inventory</faultactor>
        <detail>
            <inv:ProductError xmlns:inv="http://example.com/inventory">
                <inv:ErrorCode>PRODUCT_NOT_FOUND</inv:ErrorCode>
                <inv:ProductId>SKU-999</inv:ProductId>
                <inv:Message>No product exists with this identifier</inv:Message>
            </inv:ProductError>
        </detail>
    </soap:Fault>
</soap:Body>
```

SOAP 1.1 Fault elements:

| Element       | Required | Purpose                                                         |
| ------------- | -------- | --------------------------------------------------------------- |
| `faultcode`   | Yes      | Categorises the fault. Standard values below.                   |
| `faultstring` | Yes      | Human-readable description                                      |
| `faultactor`  | No       | URI identifying which node in the message path raised the fault |
| `detail`      | No       | Application-specific error details (any XML structure)          |

**Standard faultcode values in SOAP 1.1:**

| Code                   | Meaning                                                              |
| ---------------------- | -------------------------------------------------------------------- |
| `soap:VersionMismatch` | SOAP namespace mismatch                                              |
| `soap:MustUnderstand`  | A mandatory header was not understood                                |
| `soap:Client`          | The fault is caused by the client (bad input, auth failure)          |
| `soap:Server`          | The fault is caused by the server (internal error, database failure) |

### SOAP 1.2 Fault

SOAP 1.2 restructured faults to be more precise:

```xml
<soap:Body>
    <soap:Fault>
        <soap:Code>
            <soap:Value>soap:Receiver</soap:Value>
            <soap:Subcode>
                <soap:Value>inv:ProductNotFound</soap:Value>
            </soap:Subcode>
        </soap:Code>
        <soap:Reason>
            <soap:Text xml:lang="en">Product not found: SKU-999</soap:Text>
        </soap:Reason>
        <soap:Detail>
            <inv:ProductError xmlns:inv="http://example.com/inventory">
                <inv:ErrorCode>PRODUCT_NOT_FOUND</inv:ErrorCode>
                <inv:ProductId>SKU-999</inv:ProductId>
            </inv:ProductError>
        </soap:Detail>
    </soap:Fault>
</soap:Body>
```

SOAP 1.2 replaced `faultcode` with a structured `Code/Value/Subcode` hierarchy. Standard top-level codes:

| Code                       | Meaning                         | SOAP 1.1 equivalent |
| -------------------------- | ------------------------------- | ------------------- |
| `soap:VersionMismatch`     | SOAP version mismatch           | Same                |
| `soap:MustUnderstand`      | Mandatory header not understood | Same                |
| `soap:Sender`              | Client-side fault               | `soap:Client`       |
| `soap:Receiver`            | Server-side fault               | `soap:Server`       |
| `soap:DataEncodingUnknown` | Unknown encoding                | New in 1.2          |

## Raising Faults in spyne

### Built-in spyne Errors

spyne provides a set of common errors that automatically map to SOAP Faults:

```python
from spyne.error import (
    ResourceNotFoundError,    # -> soap:Client (404 equivalent)
    ArgumentError,            # -> soap:Client (400 equivalent)
    Unauthorized,             # -> soap:Client (401 equivalent)
    RequestTooLongError,      # -> soap:Client (413 equivalent)
    InvalidCredentialsError,  # -> soap:Client
    InternalError,            # -> soap:Server (500 equivalent)
)
```

Usage:

```python
from spyne import ServiceBase, rpc, String
from spyne.error import ResourceNotFoundError, ArgumentError

class InventoryService(ServiceBase):

    @rpc(String, _returns=Product)
    def GetProduct(ctx, product_id: str):
        if not product_id or not product_id.strip():
            raise ArgumentError("product_id cannot be empty")

        product = _PRODUCTS.get(product_id)
        if product is None:
            raise ResourceNotFoundError(product_id)

        return product
```

spyne serialises these Python exceptions into properly formatted SOAP 1.1 or 1.2 Fault elements automatically.

### Custom Typed Faults

For B2B integrations, generic error messages are often not enough. Partners may need a structured error with a machine-readable code, a tracking ID, and contextual information. Define custom fault types using `ComplexModel`:

```python
# soap_inventory/faults.py
from spyne import ComplexModel, String, Integer
from spyne.error import Fault


class ProductFaultDetail(ComplexModel):
    """Typed error detail for product-related faults."""
    class Attributes(ComplexModel.Attributes):
        target_namespace = "http://example.com/inventory"

    ErrorCode = String
    ProductId = String
    Message = String


class StockFaultDetail(ComplexModel):
    """Typed error detail for stock operation faults."""
    class Attributes(ComplexModel.Attributes):
        target_namespace = "http://example.com/inventory"

    ErrorCode = String
    ProductId = String
    RequestedCount = Integer
    MaxAllowed = Integer
    Message = String


class ProductNotFoundFault(Fault):
    """Raised when a requested product does not exist."""
    CODE = "Client.ProductNotFound"

    def __init__(self, product_id: str):
        super().__init__(
            faultcode=self.CODE,
            faultstring=f"Product not found: {product_id}",
            detail=ProductFaultDetail(
                ErrorCode="PRODUCT_NOT_FOUND",
                ProductId=product_id,
                Message=f"No product exists with identifier '{product_id}'",
            ),
        )


class InvalidStockCountFault(Fault):
    """Raised when a stock count update violates business rules."""
    CODE = "Client.InvalidStockCount"

    def __init__(self, product_id: str, requested: int, max_allowed: int = 999999):
        super().__init__(
            faultcode=self.CODE,
            faultstring=f"Invalid stock count: {requested}",
            detail=StockFaultDetail(
                ErrorCode="INVALID_STOCK_COUNT",
                ProductId=product_id,
                RequestedCount=requested,
                MaxAllowed=max_allowed,
                Message=f"Requested count {requested} is invalid",
            ),
        )
```

Use the custom faults in the service:

```python
# soap_inventory/service.py
from .faults import ProductNotFoundFault, InvalidStockCountFault

class InventoryService(ServiceBase):

    @rpc(String, _returns=Product)
    def GetProduct(ctx, product_id: str):
        product = _PRODUCTS.get(product_id)
        if product is None:
            raise ProductNotFoundFault(product_id)
        return product

    @rpc(String, Integer, _returns=StockUpdateResult)
    def UpdateStock(ctx, product_id: str, new_stock_count: int):
        if new_stock_count < 0:
            raise InvalidStockCountFault(
                product_id=product_id,
                requested=new_stock_count,
            )
        product = _PRODUCTS.get(product_id)
        if product is None:
            raise ProductNotFoundFault(product_id)
        # ... rest of update logic
```

The resulting SOAP Fault XML sent to the client will contain the typed detail:

```xml
<soap:Body>
    <soap:Fault>
        <faultcode>Client.ProductNotFound</faultcode>
        <faultstring>Product not found: SKU-999</faultstring>
        <detail>
            <inv:ProductFaultDetail xmlns:inv="http://example.com/inventory">
                <inv:ErrorCode>PRODUCT_NOT_FOUND</inv:ErrorCode>
                <inv:ProductId>SKU-999</inv:ProductId>
                <inv:Message>No product exists with identifier 'SKU-999'</inv:Message>
            </inv:ProductFaultDetail>
        </detail>
    </soap:Fault>
</soap:Body>
```

## Handling Faults in zeep Clients

zeep raises `zeep.exceptions.Fault` when the server returns a SOAP Fault response.

### Basic Fault Handling

```python
# fault_handling.py
from zeep import Client
from zeep.exceptions import Fault

client = Client("http://localhost:8000/?wsdl")

try:
    product = client.service.GetProduct(ProductId="SKU-NONEXISTENT")
except Fault as fault:
    print(f"Fault code:    {fault.code}")
    print(f"Fault message: {fault.message}")
    print(f"Fault detail:  {fault.detail}")
    # fault.detail is an lxml Element containing the <detail> XML
```

### Extracting Typed Fault Details

When the fault `<detail>` contains a structured XML element, extract it with XPath or direct attribute access:

```python
# extract_fault_detail.py
from lxml import etree
from zeep import Client
from zeep.exceptions import Fault

client = Client("http://localhost:8000/?wsdl")

INVENTORY_NS = "http://example.com/inventory"

def get_product(product_id: str) -> dict:
    try:
        result = client.service.GetProduct(ProductId=product_id)
        return {
            "ProductId": result.ProductId,
            "Name": result.Name,
            "Price": float(result.Price),
        }
    except Fault as fault:
        error_code = _extract_error_code(fault)

        if error_code == "PRODUCT_NOT_FOUND":
            raise KeyError(f"Product not found: {product_id}") from fault
        else:
            # Unknown fault — preserve original for logging
            raise RuntimeError(
                f"SOAP service error [{fault.code}]: {fault.message}"
            ) from fault


def _extract_error_code(fault: Fault) -> str | None:
    """Extract the ErrorCode element from a typed SOAP fault detail."""
    if fault.detail is None:
        return None

    # fault.detail is an lxml Element
    if isinstance(fault.detail, str):
        # Some services return detail as a string, not XML
        return None

    ns = {"inv": INVENTORY_NS}
    error_codes = fault.detail.xpath("//inv:ErrorCode", namespaces=ns)
    return error_codes[0].text if error_codes else None
```

### Distinguishing Client vs Server Faults

`soap:Client` faults indicate the request was invalid — the client should not retry. `soap:Server` faults indicate a server-side failure — the request may be retried.

```python
# fault_retry.py
import time
from zeep import Client
from zeep.exceptions import Fault, TransportError

client = Client("http://localhost:8000/?wsdl")

def get_product_with_retry(product_id: str, max_retries: int = 3) -> dict:
    for attempt in range(1, max_retries + 1):
        try:
            return client.service.GetProduct(ProductId=product_id)

        except Fault as fault:
            faultcode = fault.code or ""

            # Client faults are the caller's problem — do not retry
            if "Client" in faultcode or faultcode.startswith("soap:Client"):
                raise

            # Server faults may be transient — retry with backoff
            if attempt < max_retries:
                wait = 2 ** attempt   # 2s, 4s, 8s
                print(f"Server fault, retrying in {wait}s (attempt {attempt}/{max_retries})")
                time.sleep(wait)
            else:
                raise

        except TransportError as exc:
            # Network-level error: connection refused, timeout, etc.
            if attempt < max_retries:
                time.sleep(2 ** attempt)
            else:
                raise
```

### Handling Multiple Fault Types

When a service declares multiple fault types in the WSDL, distinguish them by error code or detail element name:

```python
# multi_fault_handling.py
from lxml import etree
from zeep import Client
from zeep.exceptions import Fault

client = Client("http://localhost:8000/?wsdl")

INVENTORY_NS = "http://example.com/inventory"

class ProductNotFoundError(Exception):
    def __init__(self, product_id: str):
        super().__init__(f"Product not found: {product_id}")
        self.product_id = product_id


class InvalidStockError(Exception):
    def __init__(self, product_id: str, count: int):
        super().__init__(f"Invalid stock count {count} for {product_id}")
        self.product_id = product_id
        self.count = count


def update_stock(product_id: str, count: int):
    try:
        return client.service.UpdateStock(
            product_id=product_id,
            new_stock_count=count,
        )
    except Fault as fault:
        code = _get_error_code(fault)

        if code == "PRODUCT_NOT_FOUND":
            raise ProductNotFoundError(product_id) from fault
        elif code == "INVALID_STOCK_COUNT":
            raise InvalidStockError(product_id, count) from fault
        else:
            raise RuntimeError(f"Unexpected SOAP fault: {fault.message}") from fault


def _get_error_code(fault: Fault) -> str | None:
    if fault.detail is None or isinstance(fault.detail, str):
        return None
    ns = {"inv": INVENTORY_NS}
    elements = fault.detail.xpath("//inv:ErrorCode", namespaces=ns)
    return elements[0].text if elements else None
```

## Logging Faults

Always log SOAP faults with enough context to diagnose issues without exposing sensitive data:

```python
# fault_logging.py
import logging
from zeep import Client
from zeep.exceptions import Fault
from lxml import etree

logger = logging.getLogger(__name__)
client = Client("http://localhost:8000/?wsdl")

def get_product(product_id: str):
    try:
        return client.service.GetProduct(ProductId=product_id)
    except Fault as fault:
        detail_xml = (
            etree.tostring(fault.detail, pretty_print=True).decode()
            if fault.detail is not None and not isinstance(fault.detail, str)
            else str(fault.detail)
        )
        logger.error(
            "SOAP fault | operation=GetProduct | product_id=%s | code=%s | message=%s | detail=%s",
            product_id,
            fault.code,
            fault.message,
            detail_xml,
        )
        raise
```

## Mapping SOAP Faults to REST Responses

When building a REST-to-SOAP proxy or a REST API backed by a SOAP service, you need to translate SOAP Faults into HTTP status codes:

```python
# fault_to_http.py
from flask import Flask, jsonify
from zeep import Client
from zeep.exceptions import Fault

app = Flask(__name__)
client = Client("http://localhost:8000/?wsdl")

FAULT_CODE_TO_HTTP = {
    "PRODUCT_NOT_FOUND": 404,
    "INVALID_STOCK_COUNT": 400,
    "UNAUTHORIZED": 401,
    "INSUFFICIENT_FUNDS": 422,
}


@app.get("/api/products/<product_id>")
def get_product(product_id: str):
    try:
        product = client.service.GetProduct(ProductId=product_id)
        return jsonify({
            "productId": product.ProductId,
            "name": product.Name,
            "price": float(product.Price),
            "inStock": product.InStock,
        })
    except Fault as fault:
        error_code = _get_error_code(fault) or "UNKNOWN"
        status = FAULT_CODE_TO_HTTP.get(error_code, 500)

        return jsonify({
            "error": fault.message,
            "code": error_code,
        }), status
```

## What's Next

You can now:

* Understand SOAP 1.1 and 1.2 Fault structures
* Raise typed faults in spyne with structured detail elements
* Catch and parse faults in zeep clients
* Build fault-aware retry logic that distinguishes client vs server errors
* Map SOAP faults to HTTP responses in REST proxies

In [Part 7](https://blog.htunnthuthu.com/getting-started/programming/soap-101/part-7-testing-performance-integration), we cover testing SOAP services with pytest, performance tuning, and patterns for integrating SOAP into modern architectures.
