Skip to content

hubspot: a POST body holding NaN, Infinity or -Infinity is refused with real's 400 naming the non-standard token - #640

Merged
nuriyoo merged 4 commits into
brekkylab:mainfrom
WynandVStaden:hubspot-non-standard-number-tokens
Oct 13, 2026
Merged

nuriyoo merged 4 commits into
brekkylab:mainfrom
WynandVStaden:hubspot-non-standard-number-tokens

Conversation

@WynandVStaden

@WynandVStaden WynandVStaden commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #626. HubSpot's batch/read and search refuse a POST body holding NaN, Infinity or -Infinity with real's 400 naming the token, unless the body holds a value real refuses ahead of it.

What changed

The refusal. A body holding NaN, Infinity or -Infinity is answered with the 400 real sends: Invalid input JSON on line <l>, column <c>: Non-standard token '<token>': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow, in an envelope with status and message and no category. The column is the one just past the token, -Infinity is named with its sign, and the token is refused wherever it stands, in a member neither route reads and as the whole body too. The three spellings inside a JSON string stay text. The refusal comes after the credential and the object type are checked.

Body order. Real reads the body in order and names whichever it meets first: the token, or a value _refuse_a_batch_read_shape or _refuse_an_operator refuses. _non_standard_number returns where the token starts along with its 400, and the two helpers take that start as before: a refusal of theirs that starts ahead of it is returned, and the token's otherwise. A value that is the token starts where the token does, so {"inputs":NaN} is the token's 400, and a null element of inputs, which real refuses only once it has read the whole body, gives way to a token anywhere in it. The line and column of each of the three refusals that name a position are counted in _position, which _token_at uses too.

A byte that does not decode. Real stops at it with a 400 these routes do not send, and _non_standard_number stops there as well: a token before the byte is named, and a body without one is read as an empty body, as json_body reads any body it cannot parse.

Not in this PR. Real's correlationId is not sent, as with the other HubSpot errors here. Bodies json.loads cannot read are an open issue of their own, and the scan answers some of them with the token's 400 where real names something else: +Infinity (real names '+Infinity'), -NaN, +NaN, NaNx, a token followed directly by a byte that does not decode, [NaN] (real refuses the array at column 1) and {"limit":NaN} sent as text/plain (real's 415). The byte that does not decode is not refused itself, where real refuses it. A lone carriage return and a byte-order mark in the position are another open issue, and so are values Backlot does not check yet: an input with no id, "after":"zzz" and "filterGroups":"x", each ahead of a token in the body measured, and the string in {"limit":"NaN"}.

Why this is what the real API does

Measured against api.hubapi.com with a read-only service key, each request carrying a cache-busting _ query parameter. On 2026-10-12, the nine bodies in the issue. On 2026-10-13 from 01:50 to 02:07 UTC, 101 POSTs to batch/read and search: the token in 20 places, its position after a line feed, a CRLF, a lone CR, a tab, a byte-order mark, multi-byte characters and an escape, each kind of refusal the two routes already make with the token behind it and five of them with the token ahead, bodies that are only the token, contacts, type ids and an unknown type name, and two requests without a valid credential. On 2026-10-13 from 08:01 to 08:06 UTC, 18 POSTs with a byte that does not decode alone, ahead of a token and behind one, in UTF-8 with and without a byte-order mark and in UTF-16.

_TOKEN_ROWS in tests/test_hubspot.py holds 52 of those bodies, each with the status real answered and, for each 400, its body. Replayed over backlot.serve(), the 119 bodies give 32 answers that differ from real, each of them in Not in this PR above, against 96 on main, and no body main answers as real does is answered differently.

Verification

  • Tests — pytest on a .[all,dev] install, Python 3.13, at 979b547 (6f9a923 merged with main at eec3c4f): 7291 passed, 2 skipped (Docker; llama_index.readers.hubspot). 43 mutations of the scan, the decode, _ahead, the two helpers and the two routes, each run against the three body tables: 38 fail. Of the five survivors, three turn a return None after _ahead into continue, which answers the same because every later value starts behind the token too; one counts a lone CR as a line break (the open issue above); one decodes the whole body leniently, which names a token behind a byte that does not decode, where real names the byte, so no measured row tells the two apart.

  • Optional surfaces that ran rather than skipped — everything .[all,dev] installs; only the Docker MCP test and the HubSpot reader test skipped. The change touches backlot/routers/hubspot.py and tests/test_hubspot.py only.

  • Lint — ruff check . && ruff format --check .: clean; scripts/gen_docs.py --check: clean; scripts/prose_check.py: no findings.

  • A test fails without this change — on main, 36 of the 52 rows of test_hubspot_refuses_a_non_standard_number_token fail.

  • A new endpoint serving corpus content is ACL-scoped, proved for both an admin and a scoped token — no endpoint is added; the refusal comes before any record is read.

  • Comments state what was measured, not the history of the fix

@nuriyoo nuriyoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this on, and for saying plainly what you could not measure. The rule is right where it applies: the message, the column just past the token, the sign on -Infinity, the envelope with no category, strings passed over with their escaped quotes, and the check made after the credential and the object type. All nine bodies from the issue and your four string bodies answer as real does at 5d68f5b, and so does the token in each of these places: a member neither route reads, an id inside an input, idProperty, propertiesWithHistory, sorts, properties, filterGroups, filters, values and highValue, after a line feed, a CRLF, a tab, multi-byte characters or an escape, after a repeated key, and on contacts. What needs changing: the case your description leaves open is measured now, and real reads the body in order, so a value the two routes already refuse is the one named when it comes before the token; a body that is nothing but the token is refused too; and the two tests can be one table.

Measured against api.hubapi.com on 2026-10-13 (01:50 to 02:07 UTC, the read-only service key behind the issue, a cache-busting _ query parameter on each request): 101 POSTs, 95 of them to companies batch/read and search (two of those without a valid credential) and the other 6 to contacts, the type ids 0-2 and 0-999 and an unknown type name. They cover the issue's nine bodies and your four, the token in 20 places across the two bodies, members neither route reads among them, the position (LF, CRLF, a lone CR, a tab, a byte-order mark, two-, three- and four-byte characters, an escape), the spellings json.loads does not read, each kind of refusal the two routes already make with the token behind it and, for five of them, ahead of it, and bodies that are only the token. Replayed over backlot.serve(), 78 differ from real at main (344d106), 38 at 5d68f5b, and 22 with the changes below. 12 of the 38 are rows main answers as real does.

1. Real names whichever comes first: the token, or a value the routes already refuse.

route       body                                              real                                         5d68f5b
batch/read  {"inputs":"x","properties":NaN}                   column 11: … from String value               column 31: Non-standard token 'NaN'
batch/read  {"inputs":[5],"properties":NaN}                   column 12: … from Number value (5)           column 31: Non-standard token 'NaN'
batch/read  {"inputs":[5,NaN]}                                column 12: … from Number value (5)           column 17: Non-standard token 'NaN'
batch/read  {"inputs":[[NaN]]}                                column 12: … from Array value                column 16: Non-standard token 'NaN'
batch/read  {"inputs":{"a":NaN}}                              column 11: … from Object value               column 19: Non-standard token 'NaN'
batch/read  {"properties":"x","inputs":[NaN]}                 column 15: … from String value ('x')         column 32: Non-standard token 'NaN'
batch/read  {"propertiesWithHistory":"x","inputs":[NaN]}      column 26: … from String value ('x')         column 43: Non-standard token 'NaN'
batch/read  {"properties":" ","inputs":[NaN]}                 unable to deserialize field "properties"     column 32: Non-standard token 'NaN'
search      {"filterGroups":[{…"BOGUS"…}],"limit":NaN}        column 64: Enum type must be one of: […]     column 99: Non-standard token 'NaN'
search      {"filterGroups":[{…"BOGUS","value":NaN}]}         column 64: Enum type must be one of: […]     column 83: Non-standard token 'NaN'
search      {"filterGroups":[{…"BOGUS"…},{"filters":Infinity}]}  column 64: Enum type must be one of: […]  column 94: Non-standard token 'Infinity'
search      {"filterGroups":[{…" "…}],"limit":NaN}            unable to deserialize field "filterGroups[0].filters[0].operator"   column 95: Non-standard token 'NaN'

the same kinds of body with the token first: the token, on both sides
batch/read  {"properties":NaN,"inputs":"x"}                   column 18: Non-standard token 'NaN'          the same
batch/read  {"inputs":[NaN,5]}                                column 15: Non-standard token 'NaN'          the same
search      {"limit":NaN,"filterGroups":[{…"BOGUS"…}]}        column 13: Non-standard token 'NaN'          the same
search      {"filterGroups":[{…"value":NaN,"operator":"BOGUS"}]}  column 64: Non-standard token 'NaN'      the same

Every real answer in the first block is the one main gives word for word (each … stands for the rest of that text), and {…"BOGUS"…} is a filter on name with that operator. Real stops at the first value it cannot read, whichever kind it is, and _refuse_a_non_standard_number runs ahead of _refuse_a_batch_read_shape and _refuse_an_operator, so the token wins in all twelve. The suggestions make the helper, renamed _non_standard_number, return where the token starts along with its 400, and the two older helpers take that start as before: a refusal of theirs that starts ahead of it is returned, the token's otherwise. A value that is the token starts where the token does, so {"inputs":NaN} stays the token's 400 rather than the float refusal main gives, and the null element of inputs, which real refuses only once the whole body has been read, gives way to the token as well ({"inputs":[null],"bogus":NaN} is the token at column 29). With the token no longer refused first, {"filterGroups":NaN} reaches _refuse_an_operator, so it now passes over a filterGroups or filters that is not a list instead of raising in enumerate.

2. A body that is only the token is refused.

route       body         real                                             5d68f5b
search      NaN          400 … column 4: Non-standard token 'NaN'         200, a page
batch/read  NaN          400 … column 4: Non-standard token 'NaN'         200, no results
search      Infinity     400 … column 9: Non-standard token 'Infinity'    200, a page
batch/read  -Infinity    400 … column 10: Non-standard token '-Infinity'  200, no results

Your description leaves a body that is not an object to json_body. These four are the body the issue is about, and the scan finds the token in them as it does inside an object, so the suggestions drop the body and guard. An array body changes with it: [NaN] becomes the token's 400 at column 5, where real refuses the array at column 1 (Cannot deserialize value of type … from Array value). Arrays belong to block 4 of the open HubSpot issue on limits, the archived flag and search bodies; main and this branch serve [NaN] with a 200.

3. The two tests as one table. The refusal rows and the string rows post a body to the same two routes and check what comes back, so they are one table with the answer as a column, the way _BATCH_BODY_ROWS and _OPERATOR_ROWS above them work: a dict is the whole 400 body, a number the status. The suggestion keeps your thirteen rows and adds 36 measured ones: more places the token stands, the positions, the order rows of 1, the bodies of 2, the unknown type and the missing credential. Its docstring names the rule and leaves the rule's text to _non_standard_number's docstring, which the current test docstring repeats.

4. One count of the position. The new helper counted the line and column with a copy of _token_at's last three lines. The suggestions move them into _position, which both use, so the two kinds of refusal cannot drift apart.

Outside this pull request. None of the 22 rows that still differ is for this pull request. 7 are positions: real counts a carriage return on its own as a line break and the three bytes of a byte-order mark in line 1's column, and _token_at does neither, for the refusals main already makes as much as for the token; that is now an issue of its own, filed today. 11 are bodies json.loads refuses or that are not an object: +Infinity, -INF, INF, nan, NaNx, -NaN and +NaN, each of which real names in its own words and Backlot serves as an empty body, [NaN] on both routes, and {"limit":NaN} sent as text/plain, which real answers with a 415; I have added them to block 4 of that open issue. The last 4 are values real refuses and Backlot does not check yet, three of them ahead of a token: an input with no id (the open issue on batch/read inputs), "after":"zzz" (block 3 of the issue with block 4), "filterGroups":"x" (the open issue on the types of filterGroups and query), and the string in {"limit":"NaN"} (block 3 again). Each of those, once it is checked, takes before the way the two helpers here do.

At 5d68f5b with the four inline suggestions and the diff in step 2:
  pytest tests/test_hubspot.py tests/test_packaging.py tests/test_docs.py tests/test_openapi.py   300 passed
  the new table, copied onto main (344d106)                     33 failed, 16 passed
  the new table at 5d68f5b                                      11 failed (findings 1 and 2), 38 passed
  ruff check . && ruff format --check .                         clean
  37 mutations of the scan, the two helpers and the two routes  33 caught
    survivors: three `return None` -> `continue` after the token, equivalent since every later
    value starts behind it too; counting a lone CR as a line break, which is the issue above
  the 101 measured bodies over backlot.serve()                  22 differ from real (all above)
Merged with main (da8df27):
  pytest on .[all,dev]                                          7079 passed, 2 skipped (Docker, llama-index-readers-hubspot)

The steps:

  1. Commit the four inline suggestions together (Files changed, "Add suggestion to batch" on each, then "Commit suggestions").
  2. Apply this diff, which is outside the lines the pull request changes. It moves _token_at's count into _position and gives the two older helpers before. CI fails on the commit from step 1 until this one is pushed, because the routes then pass before to helpers that do not take it yet.
diff --git a/backlot/routers/hubspot.py b/backlot/routers/hubspot.py
index 6aabba5..03a6f16 100644
--- a/backlot/routers/hubspot.py
+++ b/backlot/routers/hubspot.py
@@ -580,9 +580,7 @@ def _token_at(raw: bytes, path: tuple) -> tuple[int, int]:
             if text[pos] == ",":
                 pos = _WS.match(text, pos + 1).end()
         pos = found
-    line_start = text.rfind("\n", 0, pos) + 1
-    column = len(text[line_start:pos].encode("utf-8", "surrogatepass")) + 1
-    return text.count("\n", 0, pos) + 1, column
+    return _position(text, pos)
 
 
 def _position(text: str, pos: int) -> tuple[int, int]:
@@ -632,7 +630,9 @@ def _ahead(raw: bytes, path: tuple, before: tuple[int, int] | None) -> bool:
     return before is None or _token_at(raw, path) < before
 
 
-def _refuse_an_operator(raw: bytes, body: dict) -> JSONResponse | None:
+def _refuse_an_operator(
+    raw: bytes, body: dict, before: tuple[int, int] | None = None
+) -> JSONResponse | None:
     """Real's 400 for the first filter, in body order, whose operator it cannot read, or None.
 
     An operator that trims to nothing is named by its path in the body; one that trims to anything
@@ -640,27 +640,34 @@ def _refuse_an_operator(raw: bytes, body: dict) -> JSONResponse | None:
     reads the body in order and stops at the first operator of either kind, an unreadable one
     included when the filter repeats `operator` with a readable one after it. It reads an integer
     operator as an index too, answers a boolean, float, object or array operator with a different
-    400, and refuses a filter with no operator. This router models none of those cases."""
-    for i, group in enumerate(body.get("filterGroups") or []):
+    400, and refuses a filter with no operator. This router models none of those cases. `before` is
+    where `_non_standard_number` found a token, and an operator from there on is left to it."""
+    groups = body.get("filterGroups")
+    for i, group in enumerate(groups if isinstance(groups, list) else ()):
         if not isinstance(group, dict):
             continue
-        for j, f in enumerate(group.get("filters") or []):
+        filters = group.get("filters")
+        for j, f in enumerate(filters if isinstance(filters, list) else ()):
             if not isinstance(f, dict) or not isinstance(f.get("operator"), str):
                 continue
             op = f["operator"].strip(_TRIM)
+            if op in _OPERATORS or op in _OPERATOR_INDEXES:
+                continue
+            path = ("filterGroups", i, "filters", j, "operator")
+            if not _ahead(raw, path, before):
+                return None
             if op == "":
                 return _error(
                     400,
                     "Invalid input JSON: unable to deserialize field "
                     f'"filterGroups[{i}].filters[{j}].operator". Invalid value: ',
                 )
-            if op not in _OPERATORS and op not in _OPERATOR_INDEXES:
-                line, column = _token_at(raw, ("filterGroups", i, "filters", j, "operator"))
-                return _error(
-                    400,
-                    f"Invalid input JSON on line {line}, column {column}: "
-                    f"Enum type must be one of: [{', '.join(_OPERATORS)}]",
-                )
+            line, column = _token_at(raw, path)
+            return _error(
+                400,
+                f"Invalid input JSON on line {line}, column {column}: "
+                f"Enum type must be one of: [{', '.join(_OPERATORS)}]",
+            )
     return None
 
 
@@ -718,7 +725,9 @@ def _invalid_at(raw: bytes, path: tuple, text: str) -> JSONResponse:
     return _error(400, f"Invalid input JSON on line {line}, column {column}: {text}")
 
 
-def _refuse_a_batch_read_shape(raw: bytes, body: dict) -> JSONResponse | None:
+def _refuse_a_batch_read_shape(
+    raw: bytes, body: dict, before: tuple[int, int] | None = None
+) -> JSONResponse | None:
     """Real's 400 for a batch/read body it cannot read, or None. Measured against api.hubapi.com on
     2026-10-09, 2026-10-11 and 2026-10-12: real reads `inputs`, `properties` and
     `propertiesWithHistory` in body order and stops at the first that is neither null nor a list, or
@@ -726,12 +735,15 @@ def _refuse_a_batch_read_shape(raw: bytes, body: dict) -> JSONResponse | None:
     starts. A string `properties` or `propertiesWithHistory` is echoed trimmed (`_TRIM`), and one
     that trims to nothing is named by its field alone. A null element of `inputs` is refused only
     once the rest of the body has been read, in an envelope with no `category`. The elements of
-    `properties` and the members of an object in `inputs` are not checked here."""
+    `properties` and the members of an object in `inputs` are not checked here. With a `before`
+    from `_non_standard_number`, a value from there on and a null element are left to the token."""
     null_input = False
     for key, value in body.items():
         if key not in ("inputs", "properties", "propertiesWithHistory") or value is None:
             continue
         if not isinstance(value, list):
+            if not _ahead(raw, (key,), before):
+                return None
             if key == "inputs" or not isinstance(value, str):
                 return _invalid_at(raw, (key,), _not_a_list(value))
             if not value.strip(_TRIM):
@@ -749,8 +761,10 @@ def _refuse_a_batch_read_shape(raw: bytes, body: dict) -> JSONResponse | None:
             if item is None:
                 null_input = True
             elif not isinstance(item, (str, dict)):
+                if not _ahead(raw, ("inputs", i), before):
+                    return None
                 return _invalid_at(raw, ("inputs", i), _not_an_input(item))
-    if null_input:
+    if null_input and before is None:
         return JSONResponse(
             status_code=400, content={"status": "error", "message": "Input IDs must be non-null."}
         )
  1. Rewrite the description for the rule as it now is: the order of 1 replaces the "Not measured" paragraph, a body that is only the token is refused, and correlationId is the one thing left undone. The measurement for the new rows is the one above.

Comment thread backlot/routers/hubspot.py Outdated
Comment on lines +588 to +619
# A JSON string, or one of the three number tokens `json.loads` reads and JSON does not have. A
# string is matched so that a token spelled inside one is passed over.
_NON_STANDARD_NUMBER = re.compile(r'"(?:[^"\\]|\\.)*"|-?Infinity|NaN')


def _refuse_a_non_standard_number(raw: bytes) -> JSONResponse | None:
"""Real's 400 for the first `NaN`, `Infinity` or `-Infinity` in a POST body, or None.

Measured against api.hubapi.com on 2026-10-12, on companies `batch/read` and `search`: real
refuses the token wherever it stands (as `inputs`, an element of `inputs`, `properties`,
`limit`, `after` or a filter's `value`), names it with its sign, and gives the column just past
it, in an envelope with no `category`. `json.loads` reads the three as floats, so without this
the routes answer for a float there. The column is counted as `_token_at` counts one. Not
measured: a body that also holds a member real refuses for its type ahead of the token; the
token is refused first here."""
text = raw.decode(json.detect_encoding(raw), "surrogatepass")
for found in _NON_STANDARD_NUMBER.finditer(text):
token = found[0]
if token.startswith('"'):
continue
line_start = text.rfind("\n", 0, found.end()) + 1
column = len(text[line_start : found.end()].encode("utf-8", "surrogatepass")) + 1
line = text.count("\n", 0, found.end()) + 1
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"Invalid input JSON on line {line}, column {column}: Non-standard "
f"token '{token}': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow",
},
)
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 1 and finding 4. _non_standard_number returns where the token starts along with its 400, so the two older helpers can tell whether a refusal of theirs comes first, and _ahead is that comparison. The line and column are counted by _position, which _token_at uses too once the diff in step 2 is in. The docstring records the order measured on 2026-10-13 where it said the case was not measured.

Suggested change
# A JSON string, or one of the three number tokens `json.loads` reads and JSON does not have. A
# string is matched so that a token spelled inside one is passed over.
_NON_STANDARD_NUMBER = re.compile(r'"(?:[^"\\]|\\.)*"|-?Infinity|NaN')
def _refuse_a_non_standard_number(raw: bytes) -> JSONResponse | None:
"""Real's 400 for the first `NaN`, `Infinity` or `-Infinity` in a POST body, or None.
Measured against api.hubapi.com on 2026-10-12, on companies `batch/read` and `search`: real
refuses the token wherever it stands (as `inputs`, an element of `inputs`, `properties`,
`limit`, `after` or a filter's `value`), names it with its sign, and gives the column just past
it, in an envelope with no `category`. `json.loads` reads the three as floats, so without this
the routes answer for a float there. The column is counted as `_token_at` counts one. Not
measured: a body that also holds a member real refuses for its type ahead of the token; the
token is refused first here."""
text = raw.decode(json.detect_encoding(raw), "surrogatepass")
for found in _NON_STANDARD_NUMBER.finditer(text):
token = found[0]
if token.startswith('"'):
continue
line_start = text.rfind("\n", 0, found.end()) + 1
column = len(text[line_start : found.end()].encode("utf-8", "surrogatepass")) + 1
line = text.count("\n", 0, found.end()) + 1
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"Invalid input JSON on line {line}, column {column}: Non-standard "
f"token '{token}': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow",
},
)
return None
def _position(text: str, pos: int) -> tuple[int, int]:
"""The offset `pos` into the decoded body `text` as `_token_at` reports a position."""
line_start = text.rfind("\n", 0, pos) + 1
column = len(text[line_start:pos].encode("utf-8", "surrogatepass")) + 1
return text.count("\n", 0, pos) + 1, column
# A JSON string, or one of the three number tokens `json.loads` reads and JSON does not have. A
# string is matched so that a token spelled inside one is passed over.
_NON_STANDARD_NUMBER = re.compile(r'"(?:[^"\\]|\\.)*"|-?Infinity|NaN')
def _non_standard_number(raw: bytes) -> tuple[tuple[int, int], JSONResponse] | tuple[None, None]:
"""Where the first `NaN`, `Infinity` or `-Infinity` in a POST body starts, and real's 400 for
it; `(None, None)` when the body holds none.
Measured against api.hubapi.com on 2026-10-12 and 2026-10-13, on `batch/read` and `search`: real
refuses the token wherever it stands, in a member it does not read and as the whole body too,
names it with its sign, and gives the column just past it, in an envelope with no `category`. It
reads the body in order and stops at the first value it cannot read, so a value
`_refuse_a_batch_read_shape` or `_refuse_an_operator` refuses is named instead when it starts
ahead of the token (`{"inputs":[5,NaN]}` names the `5`, `{"inputs":[NaN,5]}` the token); both
take the token's start as `before`. `json.loads` reads the three as floats, so without this the
routes answer for a float there."""
text = raw.decode(json.detect_encoding(raw), "surrogatepass")
for found in _NON_STANDARD_NUMBER.finditer(text):
token = found[0]
if token.startswith('"'):
continue
line, column = _position(text, found.end())
return _position(text, found.start()), JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"Invalid input JSON on line {line}, column {column}: Non-standard "
f"token '{token}': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow",
},
)
return None, None
def _ahead(raw: bytes, path: tuple, before: tuple[int, int] | None) -> bool:
"""Whether the value at `path` starts ahead of `before`, as every value does when `before` is
None."""
return before is None or _token_at(raw, path) < before

Comment thread backlot/routers/hubspot.py Outdated
Comment on lines 992 to 997
refused = body and _refuse_a_non_standard_number(await request.body())
if refused:
return refused
# Before `after`: real refuses the operator first when the cursor is one it can parse but
# names no record (measured 2026-10-06).
refused = _refuse_an_operator(await request.body(), body)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings 1 and 2 for search: no body and guard, so a body that is only the token is refused, and an operator refusal that starts ahead of the token is the one returned, the token's 400 otherwise.

Suggested change
refused = body and _refuse_a_non_standard_number(await request.body())
if refused:
return refused
# Before `after`: real refuses the operator first when the cursor is one it can parse but
# names no record (measured 2026-10-06).
refused = _refuse_an_operator(await request.body(), body)
before, refused = _non_standard_number(await request.body())
# Before `after`: real refuses the operator first when the cursor is one it can parse but
# names no record (measured 2026-10-06).
refused = _refuse_an_operator(await request.body(), body, before) or refused

Comment thread backlot/routers/hubspot.py Outdated
Comment on lines 1060 to 1063
refused = body and _refuse_a_non_standard_number(await request.body())
if refused:
return refused
refused = _refuse_a_batch_read_shape(await request.body(), body)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings 1 and 2 for batch/read, as on search: a refusal of _refuse_a_batch_read_shape that starts ahead of the token is the one returned, the token's 400 otherwise.

Suggested change
refused = body and _refuse_a_non_standard_number(await request.body())
if refused:
return refused
refused = _refuse_a_batch_read_shape(await request.body(), body)
before, refused = _non_standard_number(await request.body())
refused = _refuse_a_batch_read_shape(await request.body(), body, before) or refused

Comment thread tests/test_hubspot.py Outdated
Comment on lines +1057 to +1115
_A_FILTER = '{"filterGroups":[{"filters":[{"propertyName":"name","operator":"EQ","value":%s}]}]}'


@pytest.mark.parametrize(
"route, raw, column, token",
[
("batch/read", '{"inputs":NaN}', 14, "NaN"),
("batch/read", '{"inputs":[NaN]}', 15, "NaN"),
("batch/read", '{"inputs":[Infinity]}', 20, "Infinity"),
("batch/read", '{"inputs":[-Infinity]}', 21, "-Infinity"),
("batch/read", _ONE + '"properties":NaN}', 40, "NaN"),
("search", '{"limit":NaN}', 13, "NaN"),
("search", '{"limit":Infinity}', 18, "Infinity"),
("search", '{"after":NaN}', 13, "NaN"),
("search", _A_FILTER % "NaN", 80, "NaN"),
],
ids=[
"inputs",
"an-input",
"an-input-infinity",
"an-input-minus-infinity",
"properties",
"limit",
"limit-infinity",
"after",
"a-filter-value",
],
)
def test_hubspot_refuses_a_non_standard_number_token(client, admin_h, route, raw, column, token):
"""`_refuse_a_non_standard_number`'s rule, one body per row: real's 400 for a `NaN`, `Infinity`
or `-Infinity`, naming the token and the column just past it, with no `category`."""
headers = {"Content-Type": "application/json", **admin_h}
r = client.post(f"/hubspot/crm/v3/objects/companies/{route}", headers=headers, content=raw)
assert (r.status_code, r.json()) == (
400,
{
"status": "error",
"message": f"Invalid input JSON on line 1, column {column}: Non-standard token "
f"'{token}': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow",
},
)


@pytest.mark.parametrize(
"route, raw, status",
[
("batch/read", '{"inputs":["NaN"]}', 207),
("batch/read", '{"inputs":[{"id":"-Infinity"}]}', 207),
("search", '{"query":"Infinity"}', 200),
("search", _A_FILTER % '"a \\"NaN\\" b"', 200),
],
ids=["an-id", "an-id-in-an-object", "a-query", "a-filter-value-with-quotes"],
)
def test_hubspot_reads_a_number_token_spelled_inside_a_string(client, admin_h, route, raw, status):
"""The three spellings are refused as tokens only: inside a string they are text, and the
routes answer as they do for any other string there."""
headers = {"Content-Type": "application/json", **admin_h}
r = client.post(f"/hubspot/crm/v3/objects/companies/{route}", headers=headers, content=raw)
assert r.status_code == status

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 3: the two tests as one table of the measured rows, with the answer as a column as in _BATCH_BODY_ROWS, your thirteen rows under their ids. Copied onto main (344d106), 33 of its 49 rows fail; at 5d68f5b, 11 do, the rows of findings 1 and 2.

Suggested change
_A_FILTER = '{"filterGroups":[{"filters":[{"propertyName":"name","operator":"EQ","value":%s}]}]}'
@pytest.mark.parametrize(
"route, raw, column, token",
[
("batch/read", '{"inputs":NaN}', 14, "NaN"),
("batch/read", '{"inputs":[NaN]}', 15, "NaN"),
("batch/read", '{"inputs":[Infinity]}', 20, "Infinity"),
("batch/read", '{"inputs":[-Infinity]}', 21, "-Infinity"),
("batch/read", _ONE + '"properties":NaN}', 40, "NaN"),
("search", '{"limit":NaN}', 13, "NaN"),
("search", '{"limit":Infinity}', 18, "Infinity"),
("search", '{"after":NaN}', 13, "NaN"),
("search", _A_FILTER % "NaN", 80, "NaN"),
],
ids=[
"inputs",
"an-input",
"an-input-infinity",
"an-input-minus-infinity",
"properties",
"limit",
"limit-infinity",
"after",
"a-filter-value",
],
)
def test_hubspot_refuses_a_non_standard_number_token(client, admin_h, route, raw, column, token):
"""`_refuse_a_non_standard_number`'s rule, one body per row: real's 400 for a `NaN`, `Infinity`
or `-Infinity`, naming the token and the column just past it, with no `category`."""
headers = {"Content-Type": "application/json", **admin_h}
r = client.post(f"/hubspot/crm/v3/objects/companies/{route}", headers=headers, content=raw)
assert (r.status_code, r.json()) == (
400,
{
"status": "error",
"message": f"Invalid input JSON on line 1, column {column}: Non-standard token "
f"'{token}': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow",
},
)
@pytest.mark.parametrize(
"route, raw, status",
[
("batch/read", '{"inputs":["NaN"]}', 207),
("batch/read", '{"inputs":[{"id":"-Infinity"}]}', 207),
("search", '{"query":"Infinity"}', 200),
("search", _A_FILTER % '"a \\"NaN\\" b"', 200),
],
ids=["an-id", "an-id-in-an-object", "a-query", "a-filter-value-with-quotes"],
)
def test_hubspot_reads_a_number_token_spelled_inside_a_string(client, admin_h, route, raw, status):
"""The three spellings are refused as tokens only: inside a string they are text, and the
routes answer as they do for any other string there."""
headers = {"Content-Type": "application/json", **admin_h}
r = client.post(f"/hubspot/crm/v3/objects/companies/{route}", headers=headers, content=raw)
assert r.status_code == status
def _token_refusal(column: int, token: str, line: int = 1) -> dict:
return {
"status": "error",
"message": f"Invalid input JSON on line {line}, column {column}: Non-standard token "
f"'{token}': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow",
}
def _nt(id, route, raw, want, object_type="companies", authed=True):
return pytest.param(route, raw, want, object_type, authed, id=id)
_A_FILTER = '{"filterGroups":[{"filters":[{"propertyName":"name","operator":"EQ","value":%s}]}]}'
_AN_OPERATOR = '"filterGroups":[{"filters":[{"propertyName":"name","operator":%s,"value":"x"}]}]'
# fmt: off
_TOKEN_ROWS = [
_nt("inputs", "batch/read", '{"inputs":NaN}', _token_refusal(14, "NaN")),
_nt("an-input", "batch/read", '{"inputs":[NaN]}', _token_refusal(15, "NaN")),
_nt("an-input-infinity", "batch/read", '{"inputs":[Infinity]}', _token_refusal(20, "Infinity")),
_nt("an-input-minus-infinity", "batch/read", '{"inputs":[-Infinity]}', _token_refusal(21, "-Infinity")),
_nt("properties", "batch/read", _ONE + '"properties":NaN}', _token_refusal(40, "NaN")),
_nt("an-id-in-an-input", "batch/read", '{"inputs":[{"id":NaN}]}', _token_refusal(21, "NaN")),
_nt("an-unknown-member", "batch/read", '{"bogus":NaN}', _token_refusal(13, "NaN")),
_nt("limit", "search", '{"limit":NaN}', _token_refusal(13, "NaN")),
_nt("limit-infinity", "search", '{"limit":Infinity}', _token_refusal(18, "Infinity")),
_nt("after", "search", '{"after":NaN}', _token_refusal(13, "NaN")),
_nt("a-filter-value", "search", _A_FILTER % "NaN", _token_refusal(80, "NaN")),
_nt("filter-groups", "search", '{"filterGroups":NaN}', _token_refusal(20, "NaN")),
_nt("filters", "search", '{"filterGroups":[{"filters":NaN}]}', _token_refusal(32, "NaN")),
_nt("contacts", "search", '{"limit":NaN}', _token_refusal(13, "NaN"), object_type="contacts"),
_nt("a-bare-token", "search", "NaN", _token_refusal(4, "NaN")),
_nt("a-bare-minus-infinity", "batch/read", "-Infinity", _token_refusal(10, "-Infinity")),
# the position, counted as `_token_at` counts one
_nt("lf", "search", '{\n"limit":NaN}', _token_refusal(12, "NaN", line=2)),
_nt("crlf", "search", '{\r\n"limit":NaN}', _token_refusal(12, "NaN", line=2)),
_nt("tab", "search", '{\t"limit":NaN}', _token_refusal(14, "NaN")),
_nt("two-byte-char-before", "search", '{"query":"é","limit":NaN}', _token_refusal(26, "NaN")),
_nt("three-and-four-byte-chars-before", "search", '{"query":"' + chr(0x20AC) + chr(0x1F600) + '","limit":NaN}', _token_refusal(31, "NaN")),
_nt("escaped-char-before", "search", '{"query":"\\u00e9","limit":NaN}', _token_refusal(30, "NaN")),
_nt("spelled-in-a-string-before", "search", '{"query":"a\\"NaN","limit":NaN}', _token_refusal(30, "NaN")),
_nt("repeated-key", "search", '{"limit":5,"limit":NaN}', _token_refusal(23, "NaN")),
# the token and a value real refuses for its type: whichever starts first is named
_nt("inputs-ahead", "batch/read", '{"inputs":"x","properties":NaN}', _batch_refusal(11, _not_a_list("String", "VALUE_STRING"))),
_nt("inputs-behind", "batch/read", '{"properties":NaN,"inputs":"x"}', _token_refusal(18, "NaN")),
_nt("an-input-ahead", "batch/read", '{"inputs":[5,NaN]}', _batch_refusal(12, _no_creator("int/Int", "Number value (5)"))),
_nt("an-input-behind", "batch/read", '{"inputs":[NaN,5]}', _token_refusal(15, "NaN")),
_nt("an-array-input-around-it", "batch/read", '{"inputs":[[NaN]]}', _batch_refusal(12, _ARRAY)),
_nt("properties-ahead", "batch/read", '{"properties":"x","inputs":[NaN]}', _batch_refusal(15, _no_creator("String", "String value ('x')"))),
_nt("blank-properties-ahead", "batch/read", '{"properties":" ","inputs":[NaN]}', _blank_field("properties")),
_nt("blank-properties-behind", "batch/read", '{"inputs":[NaN],"properties":" "}', _token_refusal(15, "NaN")),
_nt("a-null-input-ahead", "batch/read", '{"inputs":[null],"properties":NaN}', _token_refusal(34, "NaN")),
_nt("a-null-input-and-an-unknown-member", "batch/read", '{"inputs":[null],"bogus":NaN}', _token_refusal(29, "NaN")),
_nt("a-null-input-and-an-id", "batch/read", '{"inputs":[null,{"id":NaN}]}', _token_refusal(26, "NaN")),
_nt("operator-ahead", "search", "{" + _AN_OPERATOR % '"BOGUS"' + ',"limit":NaN}', _enum_refusal(1, 64)),
_nt("operator-behind", "search", '{"limit":NaN,' + _AN_OPERATOR % '"BOGUS"' + "}", _token_refusal(13, "NaN")),
_nt("operator-ahead-in-its-filter", "search", '{"filterGroups":[{"filters":[{"propertyName":"name","operator":"BOGUS","value":NaN}]}]}', _enum_refusal(1, 64)),
_nt("operator-behind-in-its-filter", "search", '{"filterGroups":[{"filters":[{"propertyName":"name","value":NaN,"operator":"BOGUS"}]}]}', _token_refusal(64, "NaN")),
_nt("operator-ahead-of-filters", "search", '{"filterGroups":[{"filters":[{"propertyName":"name","operator":"BOGUS"}]},{"filters":Infinity}]}', _enum_refusal(1, 64)),
_nt("blank-operator-ahead", "search", "{" + _AN_OPERATOR % '" "' + ',"limit":NaN}', _empty_operator_refusal(0, 0)),
_nt("blank-operator-behind", "search", '{"limit":NaN,' + _AN_OPERATOR % '" "' + "}", _token_refusal(13, "NaN")),
# after the type and the credential
_nt("after-an-unknown-type", "search", '{"limit":NaN}', {"status": "error", "message": "Unable to infer object type from: nosuchtype"}, object_type="nosuchtype"),
_nt("after-the-credential", "batch/read", '{"inputs":[NaN]}', 401, authed=False),
# inside a string the three spellings are text
_nt("an-id", "batch/read", '{"inputs":["NaN"]}', 207),
_nt("an-id-in-an-object", "batch/read", '{"inputs":[{"id":"-Infinity"}]}', 207),
_nt("a-query", "search", '{"query":"Infinity"}', 200),
_nt("a-filter-value-with-quotes", "search", _A_FILTER % '"a \\"NaN\\" b"', 200),
_nt("after-an-empty-string", "search", '{"query":"","bogus":"NaN"}', 200),
]
# fmt: on
@pytest.mark.parametrize("route, raw, want, object_type, authed", _TOKEN_ROWS)
def test_hubspot_refuses_a_non_standard_number_token(
client, admin_h, route, raw, want, object_type, authed
):
"""`_non_standard_number`'s rule, one body per row: the status and body real answered,
including which value it names when the body also holds one `_refuse_a_batch_read_shape` or
`_refuse_an_operator` refuses, and the status for the three spellings inside a string, which
real reads as text (a 207 for batch/read because no company has those ids)."""
headers = {"Content-Type": "application/json", **(admin_h if authed else {})}
url = f"/hubspot/crm/v3/objects/{object_type}/{route}"
r = client.post(url, headers=headers, content=raw.encode())
if isinstance(want, dict):
assert (r.status_code, r.json()) == (400, want)
else:
assert r.status_code == want

@nuriyoo nuriyoo added fidelity The vendor API defines the right answer; closing this needs a measurement against it bug Backlot is wrong by its own spec: a crash, a failed import, an answer the corpus does not license labels Oct 13, 2026
…use are answered in body order, and a body that is only the token is refused
@WynandVStaden

Copy link
Copy Markdown
Contributor Author

Thank you for measuring the cases I had left open, that settles both of them. e71b57e is your four suggestions and the step 2 diff, applied as written and in one commit so no commit in between fails CI. I ran them before pushing: the four test files you list give 300 passed, the whole suite on .[dev] gives 5525 passed and 37 skipped, ruff and prose_check.py are clean, and eleven of your bodies posted to backlot.serve() (the order rows, the bare token, the null element) answer with the message and column in your tables. The description is rewritten for the rule as it now is. Thank you also for carrying the UTF-8 branch into #645.

…t byte rather than answered with a 500

`_non_standard_number` decoded the whole body strictly, so a body holding a byte that does not
decode in its encoding raised `UnicodeDecodeError` on both routes, where main reads such a body as
an empty one. Real stops at that byte with a 400 of its own and names a token only when it stands
ahead of it (api.hubapi.com, 2026-10-13), so the scan now reads the text ahead of the first such
byte. Three measured rows join `_TOKEN_ROWS`: a UTF-16 body, a byte behind the token, and a
UTF-8 byte-order mark ahead of both, which pins `e.object` over `raw`.

@nuriyoo nuriyoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, and welcome to Backlot! Your second commit is the review applied exactly, in one commit so no commit in between fails CI, and the eleven bodies you posted to backlot.serve() were the right check: the order rows and the bare token are the two cases the first version answered differently.

I pushed one commit on top to finish it:

  • A body holding a byte that does not decode, such as a lone 0xFF, was a 500 on both routes, because the scan decoded the whole body strictly before looking for a token. Real stops at that byte with a 400 of its own and names a token only when it comes first (measured today), so the scan now reads the text ahead of the first such byte. Three measured rows join _TOKEN_ROWS: a UTF-16 body, a byte behind the token, and a UTF-8 byte-order mark ahead of both.
  • I rewrote the description for the merged state. One sentence in it came from my review and was wrong: +Infinity, -NaN, +NaN and NaNx are not served as an empty body on this branch but get the token's 400, where real's 400 says something else. The description now lists them under Not in this PR, and I'll update the open issue that holds those bodies once this is merged.

CI is green, so I'm squash-merging it now. With this merged you can have up to three PRs open here at a time (CONTRIBUTING, item 7 of Pull requests). Issues labelled good first issue or hacktoberfest are a good place to pick the next one.

@nuriyoo
nuriyoo merged commit 6e68bae into brekkylab:main Oct 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Backlot is wrong by its own spec: a crash, a failed import, an answer the corpus does not license fidelity The vendor API defines the right answer; closing this needs a measurement against it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hubspot: a POST body holding NaN, Infinity or -Infinity is read as a float, where real refuses the non-standard token

2 participants