Skip to content

fix(client-typescript): raise CauraApiError for a non-JSON 2xx body - #2014

Merged
erni-a merged 2 commits into
caura-ai:mainfrom
geoggrigori:fix/client-ts-non-json-2xx
Oct 11, 2026
Merged

erni-a merged 2 commits into
caura-ai:mainfrom
geoggrigori:fix/client-ts-non-json-2xx

Conversation

@geoggrigori

Copy link
Copy Markdown
Contributor

Summary

In the TypeScript client, a 2xx response whose body isn't JSON (an HTML page from a captive portal or a misconfigured proxy) now rejects with CauraApiError(200, "response was not valid JSON") instead of a bare SyntaxError. Callers that catch CauraError see it.

Related Issue

Closes #1901. This is the TypeScript side of #1899.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behavior)
  • Documentation update
  • Refactor / internal cleanup
  • Other:

What changed

  • request() wraps the success-path readResponseJson(res) as the issue proposes. A SyntaxError becomes CauraApiError(res.status, "response was not valid JSON"), and the original SyntaxError is kept as error.cause. Any other error is rethrown as before, so a body-read failure is still a TransportError.
  • readResponseJson and raiseForStatus are untouched, so a non-JSON error body still maps by status.
  • CauraApiError takes an optional fourth ErrorOptions argument, as TransportError already passes { cause }. The existing three-argument calls and the subclasses are unchanged.

How Has This Been Tested?

npm test in clients/typescript/ (tsc + node --test): 56 passed.

  • The old test transport mapping does not wrap invalid JSON asserted the bare SyntaxError the issue describes as the bug. It's replaced by a 2xx response that is not JSON is a CauraApiError, not a bare SyntaxError. That test covers health() and write(), and checks instanceof CauraApiError and CauraError, not TransportError, statusCode === 200, the message and the SyntaxError cause. It failed before the fix.
  • New a non-JSON error body still maps by status: a 502 with an HTML body is still CauraApiError("[502] request failed"). It passes before and after, and guards the error path that readResponseJson keeps rethrowing for.
  • prettier --check passes on both changed files.

Checklist

  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes (or explained why none are needed)
  • ruff check and ruff format --check pass: N/A, no Python touched
  • mypy passes: N/A, no Python touched
  • pytest passes locally: N/A, no Python touched. The TypeScript suite passes (above).
  • I have updated relevant documentation (README, docs, etc.): no documented behavior changes
  • I have updated CHANGELOG.md under the Unreleased section (if user-facing): release-please builds it from the fix(client-typescript): subject

Additional Notes

Commit is DCO signed-off.

readResponseJson rethrows SyntaxError on purpose, so raiseForStatus can
tell that an error body isn't JSON. On the success path nothing caught
it, so a 2xx with an HTML body (a captive portal, a misconfigured proxy)
escaped request() as a bare SyntaxError, past callers catching
CauraError. request() now turns it into CauraApiError(status, "response
was not valid JSON") and keeps the SyntaxError as its cause; the error
path is unchanged.

CauraApiError takes an optional ErrorOptions so the cause is kept.

Closes caura-ai#1901

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Geovana Grigorio <grigoricontato@gmail.com>
@geoggrigori
geoggrigori requested a review from a team as a code owner October 8, 2026 23:02

@erni-a erni-a 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 @geoggrigori. Mirrors #2013 on the TypeScript side, keeps the SyntaxError as cause through the new ErrorOptions parameter, and the 502 test pins that non-JSON error bodies still map by status. Merging in this pass.

@erni-a
erni-a merged commit 9ba140d into caura-ai:main Oct 11, 2026
19 checks passed
@ghost ghost mentioned this pull request Oct 11, 2026
eldad-caura-ai pushed a commit that referenced this pull request Oct 11, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>backend: 3.30.7</summary>

##
[3.30.7](backend-v3.30.6...backend-v3.30.7)
(2026-10-11)


### Bug Fixes

* a bulk item's null weight gets the column default (L-97)
([#2036](#2036))
([25bc89e](25bc89e))
* **client-python:** raise CauraAPIError for non-JSON 2xx bodies
([#2013](#2013))
([d6580f7](d6580f7))
* **client-typescript:** raise CauraApiError for a non-JSON 2xx body
([#2014](#2014))
([9ba140d](9ba140d))
* **clients:** parse HTTP-date Retry-After values
([#2010](#2010))
([f575803](f575803))
* **events:** type pull_executor concretely in _hold_leases
([#1596](#1596))
([#1698](#1698))
([45dff80](45dff80))
* **release:** keep the core-operations version in sync
([#1717](#1717))
([96a7907](96a7907))
* storage answers for the database it has (B28)
([#2033](#2033))
([67a0a57](67a0a57))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com>
Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.com>
eldad-caura-ai added a commit that referenced this pull request Oct 15, 2026
## Summary

Bumps both SDK clients to **1.1.0** so the next publish carries what has
merged since the last releases (O14). Neither client has been published
since `caura-client` 1.0.3 on PyPI (tag `caura-client-v1.0.3`) and
`@caura/client` 1.0.2 on npm (tag `caura-client-ts-v1.0.2`).

Versions only: `clients/python/pyproject.toml` and
`caura_client/_version.py`; `clients/typescript/package.json`,
`package-lock.json` (its own two entries) and `src/version.ts`.

## What 1.1.0 carries

Both clients:
- #1807: refuse to send the API key over plain HTTP to another host
- #2010: parse HTTP-date `Retry-After` values
- #2012: `search()` keeps the response envelope (`SearchResult`, still a
list or array), `recall()` sends `items_alias: false`, and
`caller_agent_id` / `callerAgentId` lets a tenant-key client read as an
agent (opt-in)
- #2035: `stats()`
- #2071: the memory map, spend and value methods

Python: #2013 (`CauraAPIError` for a non-JSON 2xx body), plus #1775 and
#1909's client parts. TypeScript: #2014 (`CauraApiError` for a non-JSON
2xx body), #1667 (typing and response timeouts).

**Why minor:** new methods and fields. `search()` still returns a list
subclass (an array in TypeScript), so existing callers keep working. One
visible change: `recall()`'s `raw` no longer contains `items` unless
`items_alias` is passed.

**Server versions:** `stats()` needs a backend with #2035 (3.32.0 or
later) and the memory map, spend and value methods need #2071 (3.33.0 or
later). Against an older server those calls get a 404; everything else
works as before.

## Release, after merge

Each tag runs its publish workflow:
- `caura-client-v1.1.0` → `publish-python-client.yml` (PyPI
`caura-client`)
- `caura-client-ts-v1.1.0` → `publish-npm-client.yml` (npm
`@caura/client`)

The `caura`, `caura-sdk` (PyPI) and unscoped `caura-client` (npm)
packages depend on `caura-client>=1.0.0` / `@caura/client ^1.0.1`, so
they pick up 1.1.0 without a release of their own.

## Test plan

- [ ] CI: the Python client's version test (`__version__` agrees with
`pyproject.toml`) and the TypeScript client's build and tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 20, 2026
L-237: stats(), added after #2013 and #2014, read its body without
the checks the other methods have. In Python a 2xx page that is not
JSON (a captive portal, a proxy) escaped as a bare JSONDecodeError,
and a JSON body that is not an object as AttributeError, though the
README says catching CauraError covers HTTP failures. In TypeScript
a body that is not an object came back as zero counters. Both now
raise "stats response must be a JSON object", as search() and
recall() do.

L-238: retry_after could hold a delay nobody can wait on. Python's
float() takes "nan", "inf", "-5" and digit strings past float range;
time.sleep() raises on NaN and negatives and overflows on infinity,
and asyncio.sleep(inf) never returns. TypeScript's Number() read
"-5" as -5 and a blank header as 0. Both now report None/null unless
the delay is finite and non-negative, as the collaboration client's
own parser already drops negatives.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 20, 2026
…2116)

## Summary

Two Low findings in the client SDKs, from the security audit's review of
the client changes merged since its last recheck. Both are in the
clients' response handling, so they share one PR.

**L-237: `stats()` skips the response checks every other method has.**
It was added in #2035, after #2013 and #2014 made a non-JSON 2xx body a
`CauraAPIError`.
- **Python:** `stats()` reads `response.json()` directly. A
captive-portal or proxy page with a 200 status escapes as a bare
`JSONDecodeError`, although the README says catching `CauraError` covers
HTTP failures. A JSON body that isn't an object raises `AttributeError`.
- **TypeScript:** `stats()` returns zero counters with a non-object
`raw` for a body that isn't an object.

`search()` and `recall()` in both clients reject such bodies with `"...
response must be a JSON object"`.

**L-238: `retry_after` can hold a value nobody can wait on.**
- **Python:** `float()` accepts `nan`, `inf`, `-5`, and digit strings
past float range (which become `inf`). A caller that sleeps on it fails:
`time.sleep()` raises for NaN and negatives and overflows on infinity,
and `asyncio.sleep(inf)` never returns.
- **TypeScript:** `Number()` reads `-5` as -5 and a blank header as 0.

The collaboration client's own parser
(`caura_bus_core.retry.retry_after_seconds`) already rejects negatives.

## Fix

- **Both clients:** `stats()` rejects a body that is not a JSON object
with `"stats response must be a JSON object"`, the same check `search()`
and `recall()` make.
- **Python:** `stats()` reads its body through `_json()`, the helper
from #2013, so a 2xx page that is not JSON raises `CauraAPIError` like
every other call.
- **Retry-After:** `retry_after` is kept only when it is finite and
non-negative; otherwise it is `None`/`null`. TypeScript also treats a
blank header as absent. Valid delays and HTTP-dates (past dates still
give 0) are unchanged.

## Test plan

- [x] **Tests alone failed in CI** (commit 944953f), all on the new
cases:
- Client (Python) CI, run
[39404592562](https://github.com/caura-ai/caura/actions/runs/39404592562):
8 failed, 233 passed. The failures are `stats` in the non-JSON 2xx test
(`JSONDecodeError`), the three non-object `stats` bodies
(`AttributeError`), and `retry_after` for `-5`, `nan`, `inf` and a
400-digit value (`-5.0`, `nan`, `inf`, `inf`).
- Client (TypeScript) CI, run
[39404592529](https://github.com/caura-ai/caura/actions/runs/39404592529):
5 of 78 failed. They are the three non-object `stats` bodies (resolved
instead of rejecting), and `-5` and a blank header (`-5` and `0` instead
of `null`).
- [ ] Both pass with the fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(client-typescript): a 2xx response with a non-JSON body escapes as a bare SyntaxError, not a CauraError

2 participants