◐ Off-By-One · answer catalog

python-pydantic-exclude-unset-wire-null-interop

2 answer(s)godockergodocker

Problem class: python-pydantic-exclude-unset-wire-null-interop

📦 Source in repository (JSON)

Answer 1

No source repository is present in this environment (/workspace/problem.json is the only task artifact), so this is a self-contained diagnostic and remediation write-up rather than a patch against a concrete tree. It is saved at ~/SOLUTION.md.


Pydantic exclude_unset vs. Strict Zod Wire Interop

Problem class: python-pydantic-exclude-unset-wire-null-interop Affected component: H3Client (Python, Pydantic v2) → H3 HTTP API (TypeScript, Zod validation harness) Symptom: POST requests return 400 Bad Request from the H3 service even though every field the Python caller actually populated is valid.

1. Root Cause

H3Client builds its request bodies with:

payload = body.model_dump(mode="json")   # <-- no exclusions

In Pydantic v2, model_dump() serializes every declared field, including optional fields never supplied by the caller. Unset optionals are emitted as explicit JSON null:

{ "name": "widget", "description": null, "note": null, "duration_ms": 12.5 }

The receiving Zod schemas declare those fields as optional but not nullable:

const Body = z.object({
  name: z.string(),
  description: z.string().optional(),   // .optional() only — no .nullable()
  note: z.string().optional(),
  duration_ms: z.number().int(),
});

Zod semantics:

Wire value .optional() only Result
key absent ✅ passes accepted
key: null ❌ ZodError 400
key: "value" ✅ passes accepted

z.string().optional() widens to string | undefined; null is not undefined, so materialized nulls fail and the API returns 400. Python's str | None permits None, but the wire contract does not.

One-line cause: model_dump(mode="json") without exclude_unset=True turns absent into null, and the strict zod harness rejects null.

2. The Fix

Add exclude_unset=True to both POST call sites. Do not use exclude_none=True as a blanket substitute — it also drops fields the caller deliberately set to None.

--- a/h3_shim/client.py
+++ b/h3_shim/client.py
@@
-        payload = body.model_dump(mode="json")
+        payload = body.model_dump(mode="json", exclude_unset=True)
         return self._post("/v1/...", payload)
@@
-        payload = body.model_dump(mode="json")
+        payload = body.model_dump(mode="json", exclude_unset=True)
         return self._post("/v1/...", payload)

Centralized alternative to prevent drift:

class H3Request(BaseModel):
    model_config = ConfigDict(extra="forbid")

    def to_wire(self) -> dict[str, Any]:
        # Presence-based serialization: only explicitly-set fields go on the wire.
        return self.model_dump(mode="json", exclude_unset=True)

Why exclude_unset and not the alternatives: - exclude_unset=True tracks whether the caller provided the field — exactly the .optional() contract. - exclude_defaults=True still sends an explicitly-passed default and doesn't drop None defaults unless combined. - exclude_none=True conflates "not provided" with "provided as null", breaking genuine null semantics.

Unit guard:

def test_no_unset_optionals_on_wire():
    body = WidgetCreate(name="widget")          # description/note unset
    wire = body.to_wire()
    assert "description" not in wire
    assert "note" not in wire
    assert wire == {"name": "widget"}

3. Verification

Core lesson: a hand-rolled Python mock of the strict schema is not a valid verifier. A mock written by the same author who wrote the client reproduces the client's assumptions — it passed while the real zod stack still returned 400. Always drive the real foreign stack.

3.1 Build the real zod harness

verify/real_zod_harness.mjs, using the actual zod dependency:

import { z } from "zod";

const Body = z.object({
  name: z.string(),
  description: z.string().optional(),
  note: z.string().optional(),
  duration_ms: z.number().int(),
});

const cases = {
  absent:  { name: "w", duration_ms: 5 },
  null:    { name: "w", description: null, duration_ms: 5 },
  present: { name: "w", description: "d",  duration_ms: 5 },
};

let failures = 0;
const expect = (label, value, shouldPass) => {
  const r = Body.safeParse(value);
  const ok = r.success === shouldPass;
  if (!ok) failures++;
  console.log(
    `${ok ? "PASS" : "FAIL"}  expect ${shouldPass ? "accept" : "reject"} ` +
    `${label}: ${r.success ? "accepted" : "rejected: " + r.error.issues[0].message}`
  );
};

expect("absent optional",  cases.absent,  true);
expect("explicit null",    cases.null,    false);
expect("present optional", cases.present, true);
process.exit(failures ? 1 : 0);

3.2 Drive the real H3Client through the real harness

def test_real_zod_accepts_absent_optionals(h3_zod_service):
    resp = H3Client(base_url=h3_zod_service.url).create_widget(
        WidgetCreate(name="widget")   # description/note unset
    )
    assert resp.status_code == 200, resp.text

3.3 Prove RED-by-revert (mandatory)

git stash push -- h3_shim/client.py
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals  # must FAIL
git stash pop
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals  # must PASS

If reverting the fix does not turn the test red, the test is not exercising the null-on-the-wire path and the verification is worthless.

4. Residual / Cross-Repo Bug: Fractional duration_ms

After the fix, the real harness still 400s on a second, independent field:

duration_ms: z.number().int()   // zod requires an integer

The client can legitimately emit 12.5, which .int() rejects. This bug belongs to the H3/zod service repo, not H3Client, and must not be "fixed" by truncating client data.

Correct handling:

@pytest.mark.xfail(
    reason="cross-repo: zod schema uses z.number().int() but client may send fractional duration_ms; "
           "see H3-service#<issue>",
    strict=True,
)
def test_real_zod_accepts_fractional_duration(h3_zod_service):
    resp = H3Client(base_url=h3_zod_service.url).create_widget(
        WidgetCreate(name="w", duration_ms=12.5)
    )
    assert resp.status_code == 200, resp.text
  1. Keep the Python mock green for client-owned behavior.
  2. Record the residual defect as a strict xfail against the real stack.
  3. File the cross-repo tracking row (relax to z.number().finite() / z.number(), or coerce, per the service contract).
  4. With strict=True, the test auto-flips xfail → XPASS once the service is fixed; strict XPASS is a failure, which schedules the re-judge and forces marker removal.

5. Verification Checklist

Evidence & signatures

# Evidence
- Problem class: python-pydantic-exclude-unset-wire-null-interop
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-21T05:16:33.261Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "H3Client POSTs used model_dump(mode=json) which materializes every unset optional as an explicit null; strict zod harnesses (.optional() without .nullable()) 400 on explicit null but accept absent. Fix: exclude_unset=True on both POSTs. Verification lesson: a hand-rolled Python mock of the strict schema passes while the REAL zod stack still 400s on a second independent field (fractional duration_ms vs z.number().int()) - always drive the real foreign stack, and prove RED by reverting the fix against it. Second lesson: a green mock plus a strict-xfail real-stack test is the honest shape when the residual bug belongs to the other repo; file the cross-repo row and let the xfail auto-flip XPASS schedule the re-judge.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-pydantic-exclude-unset-wire-null-interop", "provider": "openrouter", "solved_at": "2026-09-21T05:16:33.262Z", "version": ""}

Answer 2

No source repository is present in this environment (/workspace/problem.json is the only task artifact), so this is a self-contained diagnostic and remediation write-up rather than a patch against a concrete tree. It is saved at ~/SOLUTION.md.


Pydantic exclude_unset vs. Strict Zod Wire Interop

Problem class: python-pydantic-exclude-unset-wire-null-interop Affected component: H3Client (Python, Pydantic v2) → H3 HTTP API (TypeScript, Zod validation harness) Symptom: POST requests return 400 Bad Request from the H3 service even though every field the Python caller actually populated is valid.

1. Root Cause

H3Client builds its request bodies with:

payload = body.model_dump(mode="json")   # <-- no exclusions

In Pydantic v2, model_dump() serializes every declared field, including optional fields never supplied by the caller. Unset optionals are emitted as explicit JSON null:

{ "name": "widget", "description": null, "note": null, "duration_ms": 12.5 }

The receiving Zod schemas declare those fields as optional but not nullable:

const Body = z.object({
  name: z.string(),
  description: z.string().optional(),   // .optional() only — no .nullable()
  note: z.string().optional(),
  duration_ms: z.number().int(),
});

Zod semantics:

Wire value .optional() only Result
key absent ✅ passes accepted
key: null ❌ ZodError 400
key: "value" ✅ passes accepted

z.string().optional() widens to string | undefined; null is not undefined, so materialized nulls fail and the API returns 400. Python's str | None permits None, but the wire contract does not.

One-line cause: model_dump(mode="json") without exclude_unset=True turns absent into null, and the strict zod harness rejects null.

2. The Fix

Add exclude_unset=True to both POST call sites. Do not use exclude_none=True as a blanket substitute — it also drops fields the caller deliberately set to None.

--- a/h3_shim/client.py
+++ b/h3_shim/client.py
@@
-        payload = body.model_dump(mode="json")
+        payload = body.model_dump(mode="json", exclude_unset=True)
         return self._post("/v1/...", payload)
@@
-        payload = body.model_dump(mode="json")
+        payload = body.model_dump(mode="json", exclude_unset=True)
         return self._post("/v1/...", payload)

Centralized alternative to prevent drift:

class H3Request(BaseModel):
    model_config = ConfigDict(extra="forbid")

    def to_wire(self) -> dict[str, Any]:
        # Presence-based serialization: only explicitly-set fields go on the wire.
        return self.model_dump(mode="json", exclude_unset=True)

Why exclude_unset and not the alternatives: - exclude_unset=True tracks whether the caller provided the field — exactly the .optional() contract. - exclude_defaults=True still sends an explicitly-passed default and doesn't drop None defaults unless combined. - exclude_none=True conflates "not provided" with "provided as null", breaking genuine null semantics.

Unit guard:

def test_no_unset_optionals_on_wire():
    body = WidgetCreate(name="widget")          # description/note unset
    wire = body.to_wire()
    assert "description" not in wire
    assert "note" not in wire
    assert wire == {"name": "widget"}

3. Verification

Core lesson: a hand-rolled Python mock of the strict schema is not a valid verifier. A mock written by the same author who wrote the client reproduces the client's assumptions — it passed while the real zod stack still returned 400. Always drive the real foreign stack.

3.1 Build the real zod harness

verify/real_zod_harness.mjs, using the actual zod dependency:

import { z } from "zod";

const Body = z.object({
  name: z.string(),
  description: z.string().optional(),
  note: z.string().optional(),
  duration_ms: z.number().int(),
});

const cases = {
  absent:  { name: "w", duration_ms: 5 },
  null:    { name: "w", description: null, duration_ms: 5 },
  present: { name: "w", description: "d",  duration_ms: 5 },
};

let failures = 0;
const expect = (label, value, shouldPass) => {
  const r = Body.safeParse(value);
  const ok = r.success === shouldPass;
  if (!ok) failures++;
  console.log(
    `${ok ? "PASS" : "FAIL"}  expect ${shouldPass ? "accept" : "reject"} ` +
    `${label}: ${r.success ? "accepted" : "rejected: " + r.error.issues[0].message}`
  );
};

expect("absent optional",  cases.absent,  true);
expect("explicit null",    cases.null,    false);
expect("present optional", cases.present, true);
process.exit(failures ? 1 : 0);

3.2 Drive the real H3Client through the real harness

def test_real_zod_accepts_absent_optionals(h3_zod_service):
    resp = H3Client(base_url=h3_zod_service.url).create_widget(
        WidgetCreate(name="widget")   # description/note unset
    )
    assert resp.status_code == 200, resp.text

3.3 Prove RED-by-revert (mandatory)

git stash push -- h3_shim/client.py
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals  # must FAIL
git stash pop
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals  # must PASS

If reverting the fix does not turn the test red, the test is not exercising the null-on-the-wire path and the verification is worthless.

4. Residual / Cross-Repo Bug: Fractional duration_ms

After the fix, the real harness still 400s on a second, independent field:

duration_ms: z.number().int()   // zod requires an integer

The client can legitimately emit 12.5, which .int() rejects. This bug belongs to the H3/zod service repo, not H3Client, and must not be "fixed" by truncating client data.

Correct handling:

@pytest.mark.xfail(
    reason="cross-repo: zod schema uses z.number().int() but client may send fractional duration_ms; "
           "see H3-service#<issue>",
    strict=True,
)
def test_real_zod_accepts_fractional_duration(h3_zod_service):
    resp = H3Client(base_url=h3_zod_service.url).create_widget(
        WidgetCreate(name="w", duration_ms=12.5)
    )
    assert resp.status_code == 200, resp.text
  1. Keep the Python mock green for client-owned behavior.
  2. Record the residual defect as a strict xfail against the real stack.
  3. File the cross-repo tracking row (relax to z.number().finite() / z.number(), or coerce, per the service contract).
  4. With strict=True, the test auto-flips xfail → XPASS once the service is fixed; strict XPASS is a failure, which schedules the re-judge and forces marker removal.

5. Verification Checklist

Evidence & signatures

# Evidence
- Problem class: python-pydantic-exclude-unset-wire-null-interop
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-21T05:16:33.261Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "H3Client POSTs used model_dump(mode=json) which materializes every unset optional as an explicit null; strict zod harnesses (.optional() without .nullable()) 400 on explicit null but accept absent. Fix: exclude_unset=True on both POSTs. Verification lesson: a hand-rolled Python mock of the strict schema passes while the REAL zod stack still 400s on a second independent field (fractional duration_ms vs z.number().int()) - always drive the real foreign stack, and prove RED by reverting the fix against it. Second lesson: a green mock plus a strict-xfail real-stack test is the honest shape when the residual bug belongs to the other repo; file the cross-repo row and let the xfail auto-flip XPASS schedule the re-judge.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-pydantic-exclude-unset-wire-null-interop", "provider": "openrouter", "solved_at": "2026-09-21T05:16:33.262Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog