DEV Community

Aleksander Sekowski
Aleksander Sekowski

Posted on

Your Bid Response Is Valid JSON, Passes Its Schema, and Is Still Wrong

Here is a bid response. It is well-formed JSON, every field has the right type, every required field is present, and it validates against the OpenRTB 2.6 bid response schema without complaint.

{
  "id": "req-1",
  "cur": "EUR",
  "seatbid": [{
    "seat": "dsp-9",
    "bid": [{
      "id": "b1",
      "impid": "99",
      "price": 2.5,
      "mtype": 1,
      "adm": "<VAST version=\"4.2\"><Ad/></VAST>",
      "dealid": "deal-does-not-exist"
    }]
  }]
}
Enter fullscreen mode Exit fullscreen mode

Run it through a validator on its own:

$ rtblint validate --type response resp.json
OK (OpenRTB 2.6-202606 bid response): no issues found.
Enter fullscreen mode Exit fullscreen mode

Now run the same file with the bid request that produced it:

$ rtblint validate --type response --request req.json resp.json
FAILED (OpenRTB 2.6-202606 bid response): 2 error(s), 0 warning(s).
- [error] cur: Response currency "EUR" is not among the currencies the request
  allows (USD). (openrtb.response.cur_not_allowed) · spec 4.2.1
- [error] seatbid[0].bid[0].impid: impid "99" does not match the id of any Imp
  in the bid request. (openrtb.bid.impid_unknown) · spec 4.2.3
Enter fullscreen mode Exit fullscreen mode

Same bytes. Same schema. Two errors that will get this bid discarded by every exchange that checks, and no-bid by the rest.

Why the first run cannot find them

This is not a gap in the validator. It is a property of the data.

"impid": "99" is a string, which is what the spec requires. Nothing about the value "99" is malformed. Whether it is correct depends on the set of imp[].id values in a document the validator was not given. The same is true of cur: "EUR" is a valid ISO 4217 code, and whether it is allowed depends on the request's cur array.

A schema describes one document. These are relational facts spanning two. No amount of JSON Schema will express them, because the other side of the relation is not in scope.

That distinction is worth naming precisely, because it decides where you put your effort. Structural validity is a property of a document. Coherence is a property of an exchange. Most OpenRTB tooling only checks the first one, which is why most OpenRTB bugs are in the second.

The cross-document checks that matter

Given the request alongside the response, a validator can check things that are otherwise invisible:

impid resolves to a real imp. The single most common one. An off-by-one in imp indexing, a stale cached response, or a bidder that reuses ids across auctions all produce this, and all of them look fine in isolation.

cur is in the request's allowed list. The request advertises the currencies the exchange will settle in. Bidding outside that list is not a rounding problem, it is a discarded bid.

mtype matches what the imp offered. If the imp only carried a video object, a bid with mtype: 1 (banner) is bidding on inventory that does not exist in that slot.

dealid corresponds to a deal on that imp. A private deal id that the imp never listed will not clear at the deal price, and depending on the exchange may not clear at all.

seat is one the request permits, where the request constrains seats.

The middle two produce output like this, from a bid that declares banner markup against a video-only imp and cites a deal the imp never listed:

- [error] seatbid[0].bid[0].mtype: mtype 1 declares banner markup, but imp "1"
  does not offer a banner subtype. (openrtb.bid.mtype_not_offered) · spec 4.2.3
- [warning] seatbid[0].bid[0].dealid: dealid "nope" references a deal, but imp
  "1" carries no pmp object at all; verify the deal was arranged out of band.
  (openrtb.bid.dealid_unknown) · spec 4.2.3
Enter fullscreen mode Exit fullscreen mode

Note the severities. An mtype the imp never offered is an error, because no reading of the request makes that bid servable. An unknown dealid is a warning, because deals genuinely do get arranged out of band and a validator that called that an error would be wrong often enough to get switched off.

What a single document can still tell you

Cross-validation is not the only thing missing from a plain schema check. Some incoherence lives entirely inside the response and is still beyond structural validation, because it requires interpreting a string field's contents.

adm is the obvious case. It is typed as a string, so a schema is done thinking about it the moment it confirms the type. But mtype declares what kind of markup that string is supposed to contain, and the two can disagree:

$ rtblint validate --type response resp3.json
FAILED (OpenRTB 2.6-202606 bid response): 2 error(s), 0 warning(s).
- [error] seatbid[0].bid[0].adm: mtype 4 declares native markup, but adm does not
  parse as a JSON object; a native response must be the JSON Native Markup
  Response. (openrtb.bid.adm.native_not_json) · spec 4.2.3
- [error] seatbid[0].bid[1].adm: mtype 2 declares video markup (VAST XML), but
  adm is a JSON payload. (openrtb.bid.adm.markup_type_mismatch) · spec 4.2.3
Enter fullscreen mode Exit fullscreen mode

And the one that costs the most debugging time per occurrence, double encoding:

- [error] seatbid[0].bid[2].adm: adm parses to another JSON string rather than
  markup; it looks like the creative payload was JSON-encoded twice.
  (openrtb.bid.adm.double_encoded) · spec 4.2.3
Enter fullscreen mode Exit fullscreen mode

Double encoding happens when a service serialises the creative, then a downstream service serialises the already-serialised string again. The result is a valid JSON string containing a valid JSON string, so every type check passes, and the player receives "{\"native\":...}" where it expected an object. It renders as nothing. There is no error anywhere in the chain.

Wiring it in

The reason to do this in CI rather than in a postmortem is that both documents are already sitting in your integration tests. You have a request fixture and an expected response fixture, and right now you are probably asserting on a handful of fields by hand.

$ cargo install rtblint

# one response against its request
$ rtblint validate --type response --request req.json resp.json

# many responses against one request, one JSON payload per stdin line
$ cat responses.jsonl | rtblint validate --batch --type response --request req.json

# machine-readable, for a CI annotation step
$ rtblint validate --type response --request req.json --format json resp.json
Enter fullscreen mode Exit fullscreen mode

Exit codes are 0 for valid, 1 for validation errors, 2 for usage or I/O problems, so it drops into a pipeline without a wrapper script.

If you would rather look at one payload in a browser first, the OpenRTB tester takes a request and a response and shows the findings with JSON paths. The CLI docs cover the batch and version-pinning flags, and what rtblint checks is the full list if you want to know what you are getting before installing anything.

The point

Schema validation answers "is this a well-formed OpenRTB document." That is a real question and worth answering automatically. It is just a narrower question than "will this bid win, and will the creative render," and the gap between the two is where the revenue is.

If you are only validating responses in isolation, you are checking the half of the problem that rarely breaks.

Top comments (0)