Summary
When Bring.login() is called with credentials the Bring API rejects, the package surfaces a generic Cannot Login: Failed to parse JSON (Node 20+) / Cannot Login: Unexpected token I in JSON at position 0 (Node 18) instead of the actual auth error returned by Bring. The real message — e.g. Invalid Email. — is silently discarded.
Root cause
In src/bring.ts, the login() method calls resp.json() unconditionally:
async login(): Promise<void> {
let data: AuthResponse;
try {
const resp = await fetch(`${this.url}bringauth`, {
method: 'POST',
body: new URLSearchParams({ email: this.mail, password: this.password })
});
data = await resp.json(); // <-- throws SyntaxError on text/plain bodies
} catch (e: any) {
throw new Error(`Cannot Login: ${e.message}`); // <-- now contains \"Failed to parse JSON\", not the real error
}
if ('error' in data) {
throw new Error(`Cannot Login: ${data.message}`); // <-- never reached for text/plain errors
}
…
}
For some failure modes the Bring backend responds with Content-Type: text/plain instead of JSON — at minimum I've observed it for bad credentials (Invalid Email.), and the same pattern can also surface for rate-limit notices, maintenance pages, and Cloudflare challenges. resp.json() throws, the catch block swallows the original body, and the caller has no way to tell wrong-email from wrong-password from rate-limited.
Reproduction
import Bring from 'bring-shopping';
const c = new Bring({ mail: 'definitely-not-a-real-bring-user@example.com', password: 'whatever' });
try {
await c.login();
} catch (e) {
console.error(e.message);
}
Actual output (Node 20+):
Cannot Login: Failed to parse JSON
Expected output:
Cannot Login: Invalid Email.
(Or some structured equivalent that preserves the server's actual message.)
Raw response from the API in this case:
POST https://api.getbring.com/rest/v2/bringauth
→ 401
Content-Type: text/plain
Body: Invalid Email.
Suggested fix
Read the body as text first, then conditionally parse based on Content-Type and surface non-JSON bodies verbatim:
async login(): Promise<void> {
let resp: Response;
let bodyText: string;
try {
resp = await fetch(`${this.url}bringauth`, {
method: 'POST',
body: new URLSearchParams({ email: this.mail, password: this.password })
});
bodyText = await resp.text();
} catch (e: any) {
throw new Error(`Cannot Login: ${e.message}`);
}
const ctype = resp.headers.get('content-type') || '';
if (!resp.ok || !ctype.includes('application/json')) {
// text/plain, HTML, empty body, etc. — surface the server's actual message
const snippet = bodyText.trim().slice(0, 500) || '(empty body)';
throw new Error(`Cannot Login: ${resp.status} ${snippet}`);
}
let data: AuthResponse;
try {
data = JSON.parse(bodyText);
} catch (e: any) {
throw new Error(`Cannot Login: malformed JSON (status ${resp.status}): ${bodyText.slice(0, 200)}`);
}
if ('error' in data) {
throw new Error(`Cannot Login: ${data.message}`);
}
…
}
This keeps the existing error shape (a thrown Error whose message starts with Cannot Login:) while preserving the diagnostic the API actually returned. The same pattern is worth considering for the other endpoints (loadLists, getItems, …) which have the same resp.json() → 'error' in data structure and the same failure mode under e.g. rate-limit responses.
Workaround (for users hitting this today)
We currently do a manual pre-flight POST /bringauth ourselves, inspect Content-Type + body, and surface the real Bring message before calling c.login(). Happy to open a PR with the change above if it would help.
Versions
bring-shopping@2.0.1
- Confirmed against current
master (login() unchanged from the published build)
- Node.js 20.x / Bun 1.x (both
undici and Bun's native fetch behave the same way — resp.json() throws SyntaxError on non-JSON bodies)
Summary
When
Bring.login()is called with credentials the Bring API rejects, the package surfaces a genericCannot Login: Failed to parse JSON(Node 20+) /Cannot Login: Unexpected token I in JSON at position 0(Node 18) instead of the actual auth error returned by Bring. The real message — e.g.Invalid Email.— is silently discarded.Root cause
In
src/bring.ts, thelogin()method callsresp.json()unconditionally:For some failure modes the Bring backend responds with
Content-Type: text/plaininstead of JSON — at minimum I've observed it for bad credentials (Invalid Email.), and the same pattern can also surface for rate-limit notices, maintenance pages, and Cloudflare challenges.resp.json()throws, thecatchblock swallows the original body, and the caller has no way to tell wrong-email from wrong-password from rate-limited.Reproduction
Actual output (Node 20+):
Expected output:
(Or some structured equivalent that preserves the server's actual message.)
Raw response from the API in this case:
Suggested fix
Read the body as text first, then conditionally parse based on
Content-Typeand surface non-JSON bodies verbatim:This keeps the existing error shape (a thrown
Errorwhose message starts withCannot Login:) while preserving the diagnostic the API actually returned. The same pattern is worth considering for the other endpoints (loadLists,getItems, …) which have the sameresp.json()→'error' in datastructure and the same failure mode under e.g. rate-limit responses.Workaround (for users hitting this today)
We currently do a manual pre-flight
POST /bringauthourselves, inspectContent-Type+ body, and surface the real Bring message before callingc.login(). Happy to open a PR with the change above if it would help.Versions
bring-shopping@2.0.1master(login() unchanged from the published build)undiciand Bun's native fetch behave the same way —resp.json()throwsSyntaxErroron non-JSON bodies)