Defensive JSON Parsing: When the API Response Isn't What You Expected

🌐 한국어

The three tracebacks you meet most in the first days of an API integration are KeyError, TypeError: 'NoneType' object is not subscriptable, and json.decoder.JSONDecodeError. All three have one cause: the response doesn't match the shape you saw in the docs. This article classifies the kinds of "doesn't match" and where to block each one.

⚠️ This article is for educational and informational purposes. It is not investment advice, and you alone are responsible for any losses.

There Are Only Six Ways It Breaks

  1. Not JSON at all — a gateway error page, an empty string, a maintenance notice in HTML
  2. Missing field — a key you assumed would be there isn't
  3. Value is null — the key exists but holds null
  4. Wrong type — you expected a number and got the string "12.0"
  5. Different structure — you expected a list and got a single object, or an empty array
  6. HTTP 200 but an error — a business error code such as rt_cd or code inside the body

Only case 1 surfaces as a JSONDecodeError. Cases 2 through 6 don't even raise. That's what makes them more dangerous.

Step 1 — Wrap the Parse Itself (Python)

The most common mistake is calling r.json() bare. The moment an exchange goes into maintenance it hands you HTML, and this is where it blows up.

import json, requests

def fetch_json(url, **kw):

    r = requests.get(url, timeout=(3, 10), **kw)

    r.raise_for_status()

    if not r.content:                      # empty response (204, etc.)

        return None

    try:

        return r.json()

    except json.JSONDecodeError:

        # keep only the first 200 chars — dumping it all floods the log with HTML

        raise ValueError(f"not JSON: {r.text[:200]!r}")

Note the tuple form of timeout while you're here: it's (connect timeout, read timeout). One request without a timeout can stall your entire bot indefinitely.

Step 2 — Extract Values Through Functions (null and Type Defense)

dict.get(key, default) returns the default only when the key is absent. If the key exists and the value is null, you get None straight through. So it pays to habitually append or {} on nested access.

# ❌ AttributeError if output is null

qty = resp.get("output", {}).get("qty")

# ✅ absorbs null too

qty = ((resp or {}).get("output") or {}).get("qty")

For type defense, one conversion helper is the cleanest answer. Real brokerage and exchange APIs hand you numbers as strings, and sometimes deliver an integer field as "12.0".

def as_float(v):

    if v is None or v == "":

        return None

    try:

        return float(v)

    except (TypeError, ValueError):

        return None

def as_int(v):

    if v is None or v == "":

        return None

    try:

        return int(float(v))    # absorbs "12.0" → 12 as well

    except (TypeError, ValueError):

        return None

The point is that they return None rather than raising. You're handing the caller the fact that a value is absent, not killing the bot. In exchange, the caller must not forget the None check—so write the rule explicitly: "no value means skip this cycle."

price = as_float(item.get("price"))

if price is None or price <= 0:

    log.warning("missing/invalid price — skipping: %s", item.get("symbol"))

    continue

Step 3 — Separate Errors Hiding Inside HTTP 200

Many Korean APIs return HTTP 200 even on failure. Success and failure have to be distinguished by a code field in the body. So your parse function has to watch both the transport layer and the business layer.

class APIBusinessError(Exception):

    pass

def parse(r: requests.Response) -> dict:

    r.raise_for_status()                       # transport layer

    body = r.json()

    if body.get("rt_cd") not in (None, "0"):   # business layer

        raise APIBusinessError(f"{body.get('msg_cd')}: {body.get('msg1')}")

    return body

Split them and you can split your retry policy too. Transport errors (timeouts, 502s) are worth retrying; business errors (insufficient balance, bad symbol code) will fail identically a hundred times over.

Go — Static Types Make It Quieter, Not Safer

Go's json.Unmarshal ignores unknown fields and leaves absent ones at their zero value. In other words, a field can disappear entirely without producing an error. The price arrives as 0.0 and the bot calculates with it.

type Ticker struct {

    Symbol string  `json:"symbol"`

    Price  string  `json:"price"`   // many exchanges send this as a string

}

var t Ticker

if err := json.Unmarshal(body, &t); err != nil {

    return fmt.Errorf("failed to parse ticker: %w", err)

}

price, err := strconv.ParseFloat(t.Price, 64)

if err != nil || price <= 0 {

    return fmt.Errorf("invalid price: %q", t.Price)

}

When you need to distinguish "did the field actually arrive" from "did a zero arrive," use a pointer field. Absent gives you nil; an actual zero gives you *v == 0.

type Fill struct {

    Qty *float64 `json:"qty"`   // nil = not received, 0 = genuinely zero

}

if f.Qty == nil {

    return errors.New("qty field missing")

}

Take particular care when parsing error responses. json.Unmarshal succeeds against any valid JSON, so a successful unmarshal alone doesn't prove "this really is an error envelope." Check the values as well.

if resp.StatusCode != http.StatusOK {

    var apiErr struct {

        Code int    `json:"code"`

        Msg  string `json:"msg"`

    }

    // unmarshal succeeded AND Code is actually populated → a real error envelope

    if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Code != 0 {

        return fmt.Errorf("API error %d: %s", apiErr.Code, apiErr.Msg)

    }

    return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))

}

When It Isn't JSON to Begin With — Index Defense

Real-time quote feeds are often not JSON at all but delimiter-joined strings (a ^ separator, say). Here you check the count and shape before indexing. Without these two lines, the moment one field goes missing an index-out-of-range kills the process.

fields := strings.Split(payload, "^")

if len(fields) < 44 {        // count guard — before any indexing

    return

}

symbol := strings.TrimSpace(fields[0])

if len(symbol) != 6 {        // shape guard

    return

}

Should You Add Schema Validation?

Declaring response models with a library like pydantic automates much of the defense above. Just be clear about the criteria.

  • Fewer than five endpoints with a handful of fields → the helper functions above are enough. No reason to add a dependency
  • Complex, nested responses, or unifying several exchanges into one internal model → schema validation clearly pays

Either way the principle is the same. Validate external data once at the boundary, and let only validated types flow inward. When if data.get("x") is None starts appearing in the middle of your business logic, that's a signal the boundary was designed wrong.

Summary

  • Always wrap r.json() in try, and log only the beginning of the body on failure
  • .get(k, {}) doesn't stop null → pair it with (x or {})
  • Numeric conversion should return None from a helper rather than raise
  • Separate transport-layer from business-layer errors → separate your retry policy too
  • Go stays silent when a field is missing. Always add value checks (≤ 0, empty string)
  • Use pointer fields when you must distinguish "not received" from "zero"
  • Guard length and shape before indexing

APIs change eventually. On the day a field vanishes without notice, whether your bot dies or logs a warning and moves on is decided entirely by this boundary code.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기