Skip to content

GET /api/v1/supply-chain returns 500 when SELLER_ORGANIZATION_ID is unset #74

Description

@jaanijuk

Unauthenticated GET /api/v1/supply-chain returns HTTP 500 when SELLER_ORGANIZATION_ID is not configured. The route declares only a 200 response in the served OpenAPI.

The route's default branch — reached when SELLERS_JSON_PATH is absent, and documented as returning a default single-node chain — constructs SupplyChainNodeModel with sid=seller_id at src/ad_seller/interfaces/api/routers/admin.py:278, where sid is a required str. seller_id is read as getattr(settings, "seller_organization_id", "default"). seller_organization_id is declared Optional[str] = None at src/ad_seller/config/settings.py:113, so the attribute exists and the three-argument fallback never fires. The value passed is None and pydantic raises ValidationError: Input should be a valid string.

The two sibling values read on the same call show the distinction: seller_domain and seller_name use the identical getattr idiom but are not attributes of the settings object, so their fallbacks do fire and return demo-publisher.example.com and Demo Publisher. With SELLER_ORGANIZATION_ID configured, the endpoint returns 200 and those two fallback values appear alongside the configured seller_id in a single response.

Observed on v2.4.2 (e5b367d) in WSL2 Ubuntu 24.04 and a clean Ubuntu 24.04 container, the container run from a freshly initialised datastore. The script below was run unchanged in both environments.

The script below is the exact file run in both environments:

sha256 fd6fd8890a6fb26f7038d65d7b443b12e882c810d66b3a758728c22bd7658e76

No credentials are required. Because a configured SELLER_ORGANIZATION_ID yields a 200 that is indistinguishable from the defect being absent, the script reports an unmet precondition separately from a non-reproduction: on a 200 carrying a seller_id it reports the configured condition rather than a negative result.

Reproduction

#!/usr/bin/env bash
# M reproduction — GET /api/v1/supply-chain returns 500 with default configuration
#
# Published reproduction. Prints what the server does and summarises whether the
# behaviour reproduced. Always exits 0: a build that does NOT reproduce is a valid
# and useful result, not a script failure.
#
# The runtime evidence here is the symptom. The wire cannot carry the cause — the
# 500 body is opaque — so this script establishes that the failure is specific to
# this endpoint rather than a general server fault, and shows the published schema
# the endpoint fails to satisfy. Set SELLER_LOG to a readable server log to also
# print the validation error.
#
# Usage: [BASE=host:port] [SELLER_LOG=/path/to/server.log] bash repro-m.sh
# PRECONDITION: the seller must be running with SELLER_ORGANIZATION_ID unset, so
#   that settings.seller_organization_id is None. It is declared
#   `Optional[str] = None`, so the three-argument getattr fallback in the route
#   never fires for it. If the value is configured, sid is a valid string and the
#   endpoint returns 200 — that is the precondition being absent, NOT the defect
#   being fixed, and this script reports the two differently.
#
#   pydantic-settings reads .env from the server's working directory, so the
#   cleanest way to get the unset condition without editing your .env is to launch
#   from a directory that has none:
#     mkdir -p /tmp/m-noenv && cd /tmp/m-noenv
#     uv run --project /path/to/seller-agent uvicorn \
#       ad_seller.interfaces.api.main:app --host 127.0.0.1 --port 8002
#
#   No credentials needed — this endpoint is unauthenticated.
# Creates: nothing.

B="${BASE:-localhost:8001}"

# Clear scratch files from any previous run: a failed fetch would otherwise leave
# a stale file in place and let a check pass on last run's data.
rm -f /tmp/m-*.json

echo "-- 1. GET /api/v1/supply-chain, unauthenticated, default configuration --"
SC_CODE=$(curl -sS "$B/api/v1/supply-chain" -o /tmp/m-sc.json -w "%{http_code}")
export SC_CODE
echo "HTTP $SC_CODE"
echo "  body: $(head -c 120 /tmp/m-sc.json)"
if [ "$SC_CODE" = "200" ]; then
  echo "  -- endpoint answered; reading back the configured seller_id --"
  python3 -c "
import json
try: d=json.load(open('/tmp/m-sc.json'))
except Exception: raise SystemExit('  seller_id: <unreadable body>')
print('  seller_id:',repr(d.get('seller_id')))" || true
fi

echo
echo "-- 2. control: the server is healthy and the failure is endpoint-specific --"
P_CODE=$(curl -sS "$B/products" -o /tmp/m-products.json -w "%{http_code}")
export P_CODE
echo "  GET /products HTTP $P_CODE"
python3 -c "
import json
d=json.load(open('/tmp/m-products.json')); p=d.get('products',d)
print('  catalogue size:',len(p) if isinstance(p,list) else 'n/a')" 2>/dev/null || echo "  catalogue: unreadable"

echo
echo "-- 3. the route and response contract the server itself publishes --"
curl -fsS "$B/openapi.json" -o /tmp/m-openapi.json || echo "  (openapi fetch failed)"
python3 -c "
import json,sys
try: d=json.load(open('/tmp/m-openapi.json'))
except Exception: print('  openapi: unavailable'); sys.exit(0)
paths=d.get('paths',{})
print('  /api/v1/supply-chain declared:', '/api/v1/supply-chain' in paths)
if '/api/v1/supply-chain' in paths:
    print('  declared responses:', sorted((paths['/api/v1/supply-chain'].get('get') or {}).get('responses',{}).keys()))
schemas=d.get('components',{}).get('schemas',{})
node=schemas.get('SupplyChainNodeModel') or {}
print('  SupplyChainNodeModel required:', node.get('required'))
sid=(node.get('properties') or {}).get('sid') or {}
print('  sid declared type:', sid.get('type') or sid)"

if [ -n "${SELLER_LOG:-}" ] && [ -r "${SELLER_LOG}" ]; then
  echo
  echo "-- 3b. validation error from SELLER_LOG (optional, not a summary check) --"
  grep -A3 'SupplyChainNodeModel' "$SELLER_LOG" | tail -6 | sed 's/^/  /'
fi

echo
echo "-- summary --"
python3 - <<'PYEOF'
import json, os
def _load(path):
    # A failed fetch must degrade to an unchecked box, never a traceback.
    try:
        return json.load(open(path))
    except Exception:
        return {}
oa      = _load('/tmp/m-openapi.json')
paths   = oa.get('paths', {})
schemas = oa.get('components', {}).get('schemas', {})
node    = schemas.get('SupplyChainNodeModel') or {}
sid     = (node.get('properties') or {}).get('sid') or {}
code = os.environ.get('SC_CODE')

# Precondition gate. A 200 carrying a real seller_id means SELLER_ORGANIZATION_ID
# was configured, so the defect was never exercised. Reporting that as "NO" would
# be an underdetermined result: it cannot be told apart from a genuine fix.
configured = None
if code == '200':
    configured = _load('/tmp/m-sc.json').get('seller_id')

checks = [
    ("GET /api/v1/supply-chain returned HTTP 500",        code == '500'),
    ("the route is declared in the served OpenAPI",       '/api/v1/supply-chain' in paths),
    ("SupplyChainNodeModel requires sid",                 'sid' in (node.get('required') or [])),
    ("sid is declared as a string",                       sid.get('type') == 'string'),
    ("control: GET /products returned HTTP 200",          os.environ.get('P_CODE') == '200'),
]
for label, ok in checks:
    print(f"  [{'x' if ok else ' '}] {label}")
print()
if configured:
    print(f"  PRECONDITION NOT MET: seller_id is {configured!r}, so")
    print("  SELLER_ORGANIZATION_ID was configured and the defect was never exercised.")
    print("  Relaunch with it unset (see the header) and run again.")
    print()
    print("  REPRODUCED: not tested — precondition not met")
else:
    print("  REPRODUCED:", "yes" if all(v for _, v in checks) else "NO — see unchecked boxes above")
PYEOF
exit 0

Observed

GET /api/v1/supply-chain returns HTTP 500 with an empty body. GET /products returns HTTP 200 in the same run, so the failure is specific to this route rather than a general server fault. The served OpenAPI declares the route, declares only a 200 response for it, and declares SupplyChainNodeModel.sid as a required string. Where the server log is readable, it records ValidationError: 1 validation error for SupplyChainNodeModel / sid / Input should be a valid string [input_value=None].

Not tested: whether a configured SELLERS_JSON_PATH whose primary entry carries no seller id reaches the same model construction on the other branch at admin.py:265.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions