Skip to content

Commit a10b1d7

Browse files
abidlabsclaude
andauthored
Fix JS client private-Space connections (hf_token alias) and replace raw TypeErrors/unhandled rejections with clear errors (#13657)
* Fix JS client private-Space auth and error handling (hf_token alias, no more raw 'map' TypeErrors / unhandled rejections) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Copilot review: trim space_id, slash-tolerant named endpoint lookup, queue flag from skip_queue Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: trigger CI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bad052c commit a10b1d7

12 files changed

Lines changed: 214 additions & 52 deletions

File tree

.changeset/hip-pans-punch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@gradio/client": patch
3+
---
4+
5+
Fix private Space connections and error handling in the JS client: accept the deprecated `hf_token` option as an alias for `token`, raise clear errors when a Space is private or missing, tolerate malformed `/info` payloads instead of crashing with "Cannot read properties of undefined (reading 'map')", and make `predict()` reject with real `Error` objects instead of plain status objects that surface as unhandled promise rejections.

client/js/src/client.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { submit } from "./utils/submit";
2525
import { RE_SPACE_NAME, process_endpoint } from "./helpers/api_info";
2626
import {
2727
map_names_to_ids,
28+
normalise_token_option,
2829
resolve_cookies,
2930
resolve_config,
3031
get_jwt,
@@ -200,6 +201,7 @@ export class Client {
200201
if (!options.events) {
201202
options.events = ["data"];
202203
}
204+
normalise_token_option(options);
203205

204206
this.options = options;
205207
this.current_payload = {};
@@ -230,8 +232,9 @@ export class Client {
230232
await this.resolve_cookies();
231233
}
232234

233-
await this._resolve_config().then(({ config }) =>
234-
this._resolve_heartbeat(config)
235+
await this._resolve_config().then(
236+
(res: { config: Config } | undefined) =>
237+
res?.config && this._resolve_heartbeat(res.config)
235238
);
236239

237240
try {
@@ -400,7 +403,7 @@ export class Client {
400403
load_status: "error",
401404
detail: "NOT_FOUND"
402405
});
403-
throw Error(e);
406+
throw e instanceof Error ? e : new Error(String(e));
404407
}
405408
}
406409
}

client/js/src/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ export const UNAUTHORIZED_MSG = "Not authorized to access this space. ";
3535
export const INVALID_CREDENTIALS_MSG = "Invalid credentials. Could not login. ";
3636
export const MISSING_CREDENTIALS_MSG =
3737
"Login credentials are required to access this space.";
38+
export const PRIVATE_SPACE_MSG =
39+
"Could not access this app (received a 401 response). If it is a private Hugging Face Space, pass a valid Hugging Face token to the `token` option of `Client.connect`. You can generate a token at https://huggingface.co/settings/tokens.";
40+
export const SPACE_NOT_FOUND_MSG = (space: string, status: number): string =>
41+
`Space "${space}" could not be accessed (received a ${status} response from the Hugging Face API). ` +
42+
"Check that the Space name is spelled correctly and that the Space exists. If the Space is private, " +
43+
"pass a valid Hugging Face token to the `token` option of `Client.connect`. You can generate a token at https://huggingface.co/settings/tokens.";
44+
export const NO_API_INFO_MSG =
45+
"No API information is available for this app. This can happen when the app's `/info` endpoint cannot be reached, or when the app is running a legacy version of Gradio that is not supported by this client. ";
46+
export const WS_PROTOCOL_MSG =
47+
"This app appears to be running a legacy version of Gradio (3.x or earlier) that communicates over WebSockets, which is not supported by this version of @gradio/client. Please upgrade the app to a newer version of Gradio, or connect to it with @gradio/client version 0.x.";
3848
export const NODEJS_FS_ERROR_MSG =
3949
"File system access is only available in Node.js environments";
4050
export const ROOT_URL_ERROR_MSG = "Root URL not found in client config";

client/js/src/helpers/api_info.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import {
22
HOST_URL,
33
INVALID_URL_MSG,
44
QUEUE_FULL_MSG,
5-
SPACE_METADATA_ERROR_MSG
5+
SPACE_METADATA_ERROR_MSG,
6+
SPACE_NOT_FOUND_MSG
67
} from "../constants";
78
import type {
89
ApiData,
@@ -35,21 +36,36 @@ export async function process_endpoint(
3536

3637
if (RE_SPACE_NAME.test(_app_reference)) {
3738
// app_reference is a HF space name
39+
let res: Response;
3840
try {
39-
const res = await fetch(
41+
res = await fetch(
4042
`https://huggingface.co/api/spaces/${_app_reference}/${HOST_URL}`,
4143
{ headers }
4244
);
45+
} catch (e) {
46+
throw new Error(SPACE_METADATA_ERROR_MSG);
47+
}
4348

44-
const _host = (await res.json()).host;
49+
if (res.status === 401 || res.status === 404) {
50+
// the space does not exist, or it is private and the request was made
51+
// without (or with an invalid) token
52+
throw new Error(SPACE_NOT_FOUND_MSG(_app_reference, res.status));
53+
}
4554

46-
return {
47-
space_id: app_reference,
48-
...determine_protocol(_host)
49-
};
55+
let _host: string | undefined;
56+
try {
57+
_host = (await res.json()).host;
5058
} catch (e) {
5159
throw new Error(SPACE_METADATA_ERROR_MSG);
5260
}
61+
if (!_host) {
62+
throw new Error(SPACE_METADATA_ERROR_MSG);
63+
}
64+
65+
return {
66+
space_id: _app_reference,
67+
...determine_protocol(_host)
68+
};
5369
}
5470

5571
if (RE_SPACE_DOMAIN.test(_app_reference)) {
@@ -98,7 +114,13 @@ export function transform_api_info(
98114
transformed_info[category] = {};
99115

100116
Object.entries(api_info[category]).forEach(
101-
([endpoint, { parameters, returns }]) => {
117+
([endpoint, endpoint_info]) => {
118+
// Guard against malformed or legacy `/info` payloads that are
119+
// missing `parameters`/`returns` so that we surface a usable API
120+
// description instead of crashing with
121+
// "Cannot read properties of undefined (reading 'map')".
122+
const parameters = endpoint_info?.parameters ?? [];
123+
const returns = endpoint_info?.returns ?? [];
102124
const dependencyIndex =
103125
config.dependencies.find(
104126
(dep) =>
@@ -108,22 +130,24 @@ export function transform_api_info(
108130
api_map[endpoint.replace("/", "")] ||
109131
-1;
110132

111-
const dependencyTypes =
133+
const dependency =
112134
dependencyIndex !== -1
113135
? config.dependencies.find((dep) => dep.id == dependencyIndex)
114-
?.types
136+
: undefined;
137+
138+
const dependencyTypes =
139+
dependencyIndex !== -1
140+
? dependency?.types
115141
: { generator: false, cancel: false };
116142

117143
if (
118-
dependencyIndex !== -1 &&
119-
config.dependencies.find((dep) => dep.id == dependencyIndex)?.inputs
120-
?.length !== parameters.length
144+
dependency &&
145+
Array.isArray(dependency.inputs) &&
146+
dependency.inputs.length !== parameters.length
121147
) {
122-
const components = config.dependencies
123-
.find((dep) => dep.id == dependencyIndex)!
124-
.inputs.map(
125-
(input) => config.components.find((c) => c.id === input)?.type
126-
);
148+
const components = dependency.inputs.map(
149+
(input) => config.components.find((c) => c.id === input)?.type
150+
);
127151

128152
try {
129153
components.forEach((comp, idx) => {

client/js/src/helpers/init_helpers.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { Config } from "../types";
1+
import type { ClientOptions, Config } from "../types";
22
import {
33
CONFIG_ERROR_MSG,
44
CONFIG_URL,
55
INVALID_CREDENTIALS_MSG,
66
LOGIN_URL,
77
MISSING_CREDENTIALS_MSG,
8+
PRIVATE_SPACE_MSG,
89
SPACE_METADATA_ERROR_MSG,
910
UNAUTHORIZED_MSG
1011
} from "../constants";
@@ -53,6 +54,22 @@ export async function get_jwt(
5354
}
5455
}
5556

57+
/**
58+
* The `hf_token` option was renamed to `token`, but a lot of existing code
59+
* (and the Python client) still uses `hf_token`. Accept it as an alias so
60+
* that authenticated requests are not silently sent without credentials,
61+
* which previously surfaced as "Could not resolve app config" errors when
62+
* connecting to private Spaces.
63+
*/
64+
export function normalise_token_option(options: ClientOptions): void {
65+
if (options.hf_token && !options.token) {
66+
options.token = options.hf_token;
67+
console.warn(
68+
"The `hf_token` option has been renamed to `token`. Support for `hf_token` will be removed in a future version of @gradio/client."
69+
);
70+
}
71+
}
72+
5673
export function map_names_to_ids(
5774
fns: Config["dependencies"]
5875
): Record<string, number> {
@@ -153,7 +170,14 @@ async function handleConfigResponse(
153170
authorized: boolean
154171
): Promise<Config> {
155172
if (response?.status === 401 && !authorized) {
156-
const error_data = await response.json();
173+
let error_data: any = null;
174+
try {
175+
error_data = await response.json();
176+
} catch (e) {
177+
// Unauthenticated requests to private Spaces receive a non-JSON 401
178+
// page from the Hugging Face proxy rather than a Gradio auth payload.
179+
throw new Error(PRIVATE_SPACE_MSG);
180+
}
157181
const auth_message = error_data?.detail?.auth_message;
158182
throw new Error(auth_message || MISSING_CREDENTIALS_MSG);
159183
} else if (response?.status === 401 && authorized) {
@@ -172,7 +196,9 @@ async function handleConfigResponse(
172196
throw new Error(UNAUTHORIZED_MSG);
173197
}
174198

175-
throw new Error(CONFIG_ERROR_MSG);
199+
throw new Error(
200+
`${CONFIG_ERROR_MSG}(received status ${response?.status} when fetching the app config)`
201+
);
176202
}
177203

178204
export async function resolve_cookies(this: Client): Promise<void> {

client/js/src/test/api_info.test.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
INVALID_URL_MSG,
33
QUEUE_FULL_MSG,
4-
SPACE_METADATA_ERROR_MSG
4+
SPACE_NOT_FOUND_MSG
55
} from "../constants";
66
import { beforeAll, afterEach, afterAll, it, expect, describe } from "vitest";
77
import {
@@ -10,7 +10,8 @@ import {
1010
get_type,
1111
process_endpoint,
1212
join_urls,
13-
map_data_to_params
13+
map_data_to_params,
14+
transform_api_info
1415
} from "../helpers/api_info";
1516
import { initialise_server } from "./server";
1617
import { transformed_api_info } from "./test_data";
@@ -471,19 +472,13 @@ describe("process_endpoint", () => {
471472
expect(result).toEqual(expected);
472473
});
473474

474-
it("should throw an error when fetching space metadata fails", async () => {
475+
it("should throw a clear error when the space does not exist or is private", async () => {
475476
const app_reference = "hmb/bye_world";
476477
const token = "hf_token";
477478

478-
try {
479-
await process_endpoint(app_reference, token);
480-
} catch (error) {
481-
if (error instanceof Error) {
482-
expect(error.message).toEqual(SPACE_METADATA_ERROR_MSG);
483-
} else {
484-
expect.fail("Error should not be unknown.");
485-
}
486-
}
479+
await expect(process_endpoint(app_reference, token)).rejects.toThrow(
480+
SPACE_NOT_FOUND_MSG(app_reference, 404)
481+
);
487482
});
488483

489484
it("should return the correct data when app_reference is a valid space domain", async () => {
@@ -699,3 +694,36 @@ describe("map_data_params", () => {
699694
);
700695
});
701696
});
697+
698+
describe("transform_api_info", () => {
699+
it("defaults parameters and returns to empty arrays when an endpoint entry is malformed", () => {
700+
const api_info = {
701+
named_endpoints: {
702+
// missing `parameters` and `returns`, as returned by some legacy
703+
// or misbehaving apps (see issue #10945)
704+
"/predict": {}
705+
},
706+
unnamed_endpoints: {}
707+
} as any;
708+
const config = {
709+
dependencies: [
710+
{
711+
id: 0,
712+
api_name: "predict",
713+
inputs: [1],
714+
outputs: [2],
715+
types: { generator: false, cancel: false }
716+
}
717+
],
718+
components: [
719+
{ id: 1, type: "textbox", props: {} },
720+
{ id: 2, type: "textbox", props: {} }
721+
]
722+
} as any;
723+
724+
const result = transform_api_info(api_info, config, { predict: 0 });
725+
726+
expect(result.named_endpoints["/predict"].parameters).toEqual([]);
727+
expect(result.named_endpoints["/predict"].returns).toEqual([]);
728+
});
729+
});

client/js/src/test/init.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
response_api_info
1616
} from "./test_data";
1717
import { initialise_server } from "./server";
18-
import { SPACE_METADATA_ERROR_MSG } from "../constants";
18+
import { SPACE_NOT_FOUND_MSG } from "../constants";
1919

2020
const app_reference = "hmb/hello_world";
2121
const broken_app_reference = "hmb/bye_world";
@@ -87,12 +87,28 @@ describe("Client class", () => {
8787
});
8888
});
8989

90+
test("connecting successfully to a private running app with the deprecated hf_token option", async () => {
91+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
92+
const app = await Client.connect("hmb/secret_world", {
93+
hf_token: "hf_123"
94+
});
95+
96+
expect(app.config).toEqual({
97+
...config_response,
98+
root: "https://hmb-secret-world.hf.space"
99+
});
100+
expect(warn).toHaveBeenCalledWith(
101+
expect.stringContaining("`hf_token` option has been renamed")
102+
);
103+
warn.mockRestore();
104+
});
105+
90106
test("unsuccessfully attempting to connect to a private running app", async () => {
91107
await expect(
92108
Client.connect("hmb/secret_world", {
93109
token: "hf_bad_token"
94110
})
95-
).rejects.toThrowError(SPACE_METADATA_ERROR_MSG);
111+
).rejects.toThrowError(SPACE_NOT_FOUND_MSG("hmb/secret_world", 401));
96112
});
97113

98114
test("viewing the api info of a running app", async () => {
@@ -139,7 +155,9 @@ describe("Client class", () => {
139155
test("creating a duplicate of a broken app", async () => {
140156
const duplicate = Client.duplicate(broken_app_reference);
141157

142-
await expect(duplicate).rejects.toThrow(SPACE_METADATA_ERROR_MSG);
158+
await expect(duplicate).rejects.toThrow(
159+
SPACE_NOT_FOUND_MSG(broken_app_reference, 404)
160+
);
143161
});
144162
});
145163

client/js/src/test/submit.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,6 @@ describe("predict error handling", () => {
162162
1000,
163163
"predict() never settled for an unknown endpoint"
164164
)
165-
).rejects.toThrow(
166-
"There is no endpoint matching that name of fn_index matching that number."
167-
);
165+
).rejects.toThrow('No endpoint matching "nonexistent_endpoint" was found');
168166
});
169167
});

client/js/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,11 @@ export interface DuplicateOptions extends ClientOptions {
323323

324324
export interface ClientOptions {
325325
token?: `hf_${string}`;
326+
/**
327+
* @deprecated Use `token` instead. Kept as an alias so that code written
328+
* for older versions of the client keeps working.
329+
*/
330+
hf_token?: `hf_${string}`;
326331
status_callback?: SpaceStatusCallback | null;
327332
auth?: [string, string] | null;
328333
with_null_state?: boolean;

client/js/src/utils/duplicate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Client } from "../client";
88
import { SPACE_METADATA_ERROR_MSG } from "../constants";
99
import {
1010
get_cookie_header,
11+
normalise_token_option,
1112
parse_and_set_cookies
1213
} from "../helpers/init_helpers";
1314
import { process_endpoint } from "../helpers/api_info";
@@ -16,6 +17,7 @@ export async function duplicate(
1617
app_reference: string,
1718
options: DuplicateOptions
1819
): Promise<Client> {
20+
normalise_token_option(options);
1921
const { token, private: _private, hardware, timeout, auth } = options;
2022

2123
if (hardware && !hardware_types.includes(hardware)) {

0 commit comments

Comments
 (0)