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
- Not JSON at all — a gateway error page, an empty string, a maintenance notice in HTML
- Missing field — a key you assumed would be there isn't
- Value is null — the key exists but holds
null - Wrong type — you expected a number and got the string
"12.0" - Different structure — you expected a list and got a single object, or an empty array
- HTTP 200 but an error — a business error code such as
rt_cdorcodeinside 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()intry, 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
Nonefrom 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.
댓글
댓글 쓰기