Skip to content

Commit 8738944

Browse files
poppyseedDevclaude
andauthored
fix: surface relayer/edge error messages on 401 and 403 (0.4.x) (#452)
* fix: surface relayer/edge error messages on 401 and 403 The V2 request engine assumed the reason for auth failures instead of passing through the message returned by the relayer or an intermediary (Cloudflare/Kong): - 401 hardcoded "Unauthorized, missing or invalid Zama Fhevm API Key." and never read the response body. - 403 (e.g. a Cloudflare/Kong edge block for a missing x-api-key header) was not handled at all and surfaced as "Unexpected response status 403", discarding the body. Both paths now read the response body (best-effort: relayer JSON `{ error: { message, label } }`, flat `{ message, label }`, or plain text) and surface the provided message, falling back to the previous default only when the body has nothing usable. No public type changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: surface relayer/edge error messages in shared fetch error path `throwRelayerResponseError` (src/relayer/error.ts) — used by AbstractRelayerProvider for the keyurl GET on both the V1 and V2 providers, and by the V1 input-proof path — assumed the reason for a failure and discarded the response body's message. A 403 edge block (Cloudflare/Kong) or a 401/404 from Kong therefore surfaced only a generic "HTTP error! status: <code>". It now reads the body once (relayer JSON `{ error: { message } }`, flat `{ message }`, or plain text) and appends the provided message, while still preserving the parsed body in the error cause. Tests: V1 401/403 surfacing; the keyurl 404+Kong-body test now asserts the surfaced "no Route matched with those values" message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: only surface JSON error messages, not raw text bodies Relayer/edge (Cloudflare/Kong) errors that carry a message are JSON, so the best-effort readers now surface a message only when the body parses as JSON. For non-JSON bodies they leave the previous behaviour unchanged (no appended text, empty `responseJson` in the cause) — avoiding noise like a plain "Forbidden"/"Rate Limit" body or an HTML error page leaking into the message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: drop stale 'falls through' comments after 401 break The 401 case now ends with an explicit break, so the following 429/404 cases no longer fall through to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version to 0.4.4 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: fix dead Relayer SDK docs link (404 -> docs.zama.org/protocol) The /protocol/relayer-sdk-guides section index returns 404 to crawlers (GitBook SPA), failing the linkspector CI check. Point to the protocol docs root, which resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: regenerate src/_version.ts for 0.4.4 Run `npm run version` so src/_version.ts matches package.json (was stale at 0.4.2); it feeds the version string into SDK error messages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f5365ff commit 8738944

9 files changed

Lines changed: 292 additions & 20 deletions

docs/initialization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const instance = await createInstance({
7272
```
7373

7474
{% hint style="info" %}
75-
For more information on the Relayer's part, please refer to [the Relayer SDK documentation](https://docs.zama.org/protocol/relayer-sdk-guides).
75+
For more information on the Relayer's part, please refer to [the Relayer SDK documentation](https://docs.zama.org/protocol).
7676
{% endhint %}
7777

7878
# Network Configuration

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zama-fhe/relayer-sdk",
3-
"version": "0.4.3",
3+
"version": "0.4.4",
44
"description": "fhevm Relayer SDK",
55
"main": "lib/node.js",
66
"types": "lib/node.d.ts",

src/_version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// This file is auto-generated
2-
export const version: string = '0.4.2';
2+
export const version: string = '0.4.4';
33
export const sdkName: string = '@zama-fhe/relayer-sdk';

src/relayer-provider/v1/RelayerV1Provider.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,48 @@ describeIfFetchMock('RelayerV1Provider - Auth on GET requests', () => {
119119
expect(headers['x-api-key']).toBeUndefined();
120120
});
121121
});
122+
123+
////////////////////////////////////////////////////////////////////////////////
124+
// Error message surfacing (401 / 403)
125+
////////////////////////////////////////////////////////////////////////////////
126+
127+
describeIfFetchMock('RelayerV1Provider - error message surfacing', () => {
128+
const relayerUrlV1 = `${SepoliaConfig.relayerUrl!}/v1`;
129+
130+
beforeEach(() => {
131+
fetchMock.removeRoutes();
132+
});
133+
134+
it('v1: surfaces a 403 (edge/gateway) error message', async () => {
135+
// Cloudflare/Kong style flat `{ message, label }` body.
136+
fetchMock.get(`${relayerUrlV1}/keyurl`, {
137+
status: 403,
138+
body: {
139+
message: 'Unauthorized. Missing or invalid Zama API Key',
140+
label: 'unauthorized',
141+
},
142+
});
143+
144+
const provider = new RelayerV1Provider({ relayerUrl: relayerUrlV1 });
145+
await expect(provider.fetchGetKeyUrl()).rejects.toThrow(
146+
'Unauthorized. Missing or invalid Zama API Key',
147+
);
148+
});
149+
150+
it('v1: surfaces a 401 relayer error message', async () => {
151+
fetchMock.get(`${relayerUrlV1}/keyurl`, {
152+
status: 401,
153+
body: {
154+
error: {
155+
message: 'API key not passed via x-api-key header',
156+
label: 'unauthorized',
157+
},
158+
},
159+
});
160+
161+
const provider = new RelayerV1Provider({ relayerUrl: relayerUrlV1 });
162+
await expect(provider.fetchGetKeyUrl()).rejects.toThrow(
163+
'API key not passed via x-api-key header',
164+
);
165+
});
166+
});

src/relayer-provider/v2/RelayerV2AsyncRequest.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,75 @@ describeIfFetchMock('RelayerV2AsyncRequest - Auth on GET requests', () => {
383383
expect(getHeaders['x-api-key']).toBeUndefined();
384384
});
385385
});
386+
387+
////////////////////////////////////////////////////////////////////////////////
388+
// Auth error message surfacing (401 / 403)
389+
////////////////////////////////////////////////////////////////////////////////
390+
391+
describeIfFetchMock('RelayerV2AsyncRequest - auth error surfacing', () => {
392+
beforeEach(() => {
393+
fetchMock.removeRoutes();
394+
fetchMock.callHistory.clear();
395+
});
396+
397+
afterAll(async () => {
398+
await fetchMock.callHistory.flush(true);
399+
});
400+
401+
it('v2: 401 surfaces the relayer-provided error message', async () => {
402+
fetchMock.post(requestUrl, {
403+
status: 401,
404+
body: {
405+
error: {
406+
label: 'unauthorized',
407+
message: 'API key not passed via x-api-key header',
408+
},
409+
},
410+
});
411+
412+
const relayerRequest = new RelayerV2AsyncRequest({
413+
relayerOperation: 'INPUT_PROOF',
414+
url: requestUrl,
415+
payload,
416+
});
417+
418+
await expect(relayerRequest.run()).rejects.toThrow(
419+
'API key not passed via x-api-key header',
420+
);
421+
});
422+
423+
it('v2: 403 (edge/gateway) surfaces the provided error message', async () => {
424+
// Cloudflare/Kong style flat `{ message, label }` body.
425+
fetchMock.post(requestUrl, {
426+
status: 403,
427+
body: {
428+
message: 'Unauthorized. Missing or invalid Zama API Key',
429+
label: 'unauthorized',
430+
},
431+
});
432+
433+
const relayerRequest = new RelayerV2AsyncRequest({
434+
relayerOperation: 'INPUT_PROOF',
435+
url: requestUrl,
436+
payload,
437+
});
438+
439+
await expect(relayerRequest.run()).rejects.toThrow(
440+
'Unauthorized. Missing or invalid Zama API Key',
441+
);
442+
});
443+
444+
it('v2: 401 without a body falls back to the default message', async () => {
445+
fetchMock.post(requestUrl, { status: 401 });
446+
447+
const relayerRequest = new RelayerV2AsyncRequest({
448+
relayerOperation: 'INPUT_PROOF',
449+
url: requestUrl,
450+
payload,
451+
});
452+
453+
await expect(relayerRequest.run()).rejects.toThrow(
454+
'Unauthorized, missing or invalid Zama Fhevm API Key.',
455+
);
456+
});
457+
});

src/relayer-provider/v2/RelayerV2AsyncRequest.ts

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ import {
5555
assertIsRelayerV2GetResponseQueued,
5656
assertIsRelayerV2PostResponseQueued,
5757
} from './guards/RelayerV2ResponseQueued';
58-
import { isNonEmptyString, safeJSONstringify } from '@base/string';
58+
import {
59+
isNonEmptyString,
60+
isRecordStringProperty,
61+
safeJSONstringify,
62+
} from '@base/string';
5963
import { sdkName, version } from '../../_version';
6064
import { RelayerV2TimeoutError } from './errors/RelayerV2TimeoutError';
6165
import { RelayerV2AbortError } from './errors/RelayerV2AbortError';
@@ -483,6 +487,13 @@ export class RelayerV2AsyncRequest {
483487

484488
// At this stage: `terminated` is guaranteed to be `false`.
485489

490+
// 403 is not part of the relayer's typed response set — it is an
491+
// edge/gateway rejection (e.g. Cloudflare/Kong). Surface its body message
492+
// before narrowing to the relayer status union.
493+
if (response.status === 403) {
494+
await this._throwForbiddenError(response);
495+
}
496+
486497
const responseStatus: RelayerV2PostResponseStatus =
487498
response.status as RelayerV2PostResponseStatus;
488499

@@ -558,11 +569,14 @@ export class RelayerV2AsyncRequest {
558569
// RelayerV2ApiError401
559570
// falls through
560571
case 401: {
561-
this._throwUnauthorizedError(responseStatus);
572+
await this._throwUnauthorizedError(response, responseStatus);
573+
// `_throwUnauthorizedError` always throws; `break` only satisfies the
574+
// compiler's switch fallthrough check (awaiting a `Promise<never>` is
575+
// not recognised as terminating).
576+
break;
562577
}
563578
// RelayerV2ResponseFailed
564579
// RelayerV2ApiError429
565-
// falls through
566580
case 429: {
567581
// Retry
568582
// Rate Limit error (Cloudflare/Kong/Relayer), reason in message
@@ -707,6 +721,13 @@ export class RelayerV2AsyncRequest {
707721

708722
// At this stage: `terminated` is guaranteed to be `false`.
709723

724+
// 403 is not part of the relayer's typed response set — it is an
725+
// edge/gateway rejection (e.g. Cloudflare/Kong). Surface its body message
726+
// before narrowing to the relayer status union.
727+
if (response.status === 403) {
728+
await this._throwForbiddenError(response);
729+
}
730+
710731
const responseStatus: RelayerV2GetResponseStatus =
711732
response.status as RelayerV2GetResponseStatus;
712733

@@ -926,9 +947,12 @@ export class RelayerV2AsyncRequest {
926947
}
927948
// falls through
928949
case 401: {
929-
this._throwUnauthorizedError(responseStatus);
950+
await this._throwUnauthorizedError(response, responseStatus);
951+
// `_throwUnauthorizedError` always throws; `break` only satisfies the
952+
// compiler's switch fallthrough check (awaiting a `Promise<never>` is
953+
// not recognised as terminating).
954+
break;
930955
}
931-
// falls through
932956
case 404: {
933957
// Abort
934958
// Wrong jobId, incorrect format or unknown value etc.
@@ -1578,18 +1602,97 @@ export class RelayerV2AsyncRequest {
15781602
* Throws an unauthorized error for 401 responses.
15791603
* @throws {RelayerV2ResponseApiError} Always throws with 'unauthorized' label.
15801604
*/
1581-
private _throwUnauthorizedError(
1605+
private async _throwUnauthorizedError(
1606+
response: Response,
15821607
status: Extract<RelayerFailureStatus, 401>,
1583-
): never {
1608+
): Promise<never> {
1609+
// Surface the message provided by the relayer or an intermediary
1610+
// (e.g. Kong) instead of assuming the reason for the 401.
1611+
const { message } = await this._readResponseErrorMessage(response);
15841612
this._throwRelayerV2ResponseApiError({
15851613
status,
15861614
relayerApiError: {
15871615
label: 'unauthorized',
1588-
message: 'Unauthorized, missing or invalid Zama Fhevm API Key.',
1616+
message: isNonEmptyString(message)
1617+
? message
1618+
: 'Unauthorized, missing or invalid Zama Fhevm API Key.',
15891619
},
15901620
});
15911621
}
15921622

1623+
/**
1624+
* Throws a 403 error.
1625+
*
1626+
* 403 is not part of the relayer's typed response set — it indicates the
1627+
* request was blocked by an edge/gateway (e.g. Cloudflare or Kong) before
1628+
* reaching the relayer, most commonly because of a missing or invalid
1629+
* `x-api-key` header. Surface the message provided by the intermediary
1630+
* instead of a generic "unexpected status".
1631+
* @throws {RelayerV2ResponseStatusError} Always throws.
1632+
*/
1633+
private async _throwForbiddenError(response: Response): Promise<never> {
1634+
const { message } = await this._readResponseErrorMessage(response);
1635+
throw new RelayerV2ResponseStatusError({
1636+
fetchMethod: this._fetchMethod!,
1637+
status: 403,
1638+
url: this._url,
1639+
jobId: this._jobId,
1640+
operation: this._relayerOperation,
1641+
elapsed: this._elapsed,
1642+
retryCount: this._retryCount,
1643+
state: { ...this._state },
1644+
...(isNonEmptyString(message) ? { details: message } : {}),
1645+
});
1646+
}
1647+
1648+
/**
1649+
* Best-effort extraction of the error message and label provided by the
1650+
* relayer or an intermediary (e.g. Cloudflare or Kong). The body may be a
1651+
* relayer JSON error (`{ error: { message, label } }`), a flat
1652+
* `{ message, label }`, or plain text. Never throws — returns `{}` when
1653+
* nothing usable can be read.
1654+
*/
1655+
private async _readResponseErrorMessage(
1656+
response: Response,
1657+
): Promise<{ message?: string | undefined; label?: string | undefined }> {
1658+
let text: string;
1659+
try {
1660+
text = await response.text();
1661+
} catch {
1662+
return {};
1663+
}
1664+
1665+
if (!isNonEmptyString(text)) {
1666+
return {};
1667+
}
1668+
1669+
try {
1670+
const json: unknown = JSON.parse(text);
1671+
// Relayer errors are nested under `error`; edge/gateway errors
1672+
// (Cloudflare/Kong) are usually flat `{ message, label }`.
1673+
const err: unknown =
1674+
typeof json === 'object' && json !== null && 'error' in json
1675+
? (json as { error: unknown }).error
1676+
: json;
1677+
1678+
const message = isRecordStringProperty(err, 'message')
1679+
? err.message
1680+
: undefined;
1681+
const label = isRecordStringProperty(err, 'label')
1682+
? err.label
1683+
: undefined;
1684+
1685+
if (message !== undefined || label !== undefined) {
1686+
return { message, label };
1687+
}
1688+
} catch {
1689+
// Body was not JSON — relayer/edge errors that carry a message are JSON,
1690+
// so there is nothing usable to surface.
1691+
}
1692+
1693+
return {};
1694+
}
1695+
15931696
/**
15941697
* Throws a relayer API error with the given status and error details.
15951698
* @throws {RelayerV2ResponseApiError} Always throws with the provided error details.

src/relayer-provider/v2/RelayerV2Provider_keyurl.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,11 @@ describeIfFetchMock('RelayerV2Provider', () => {
234234
'Expected fetchGetKeyUrl to throw an error, but it did not.',
235235
);
236236
} catch (e) {
237-
// Error message
238-
expect(String(e)).toStrictEqual('Error: HTTP error! status: 404');
237+
// Error message — the message provided by the relayer/intermediary
238+
// (here Kong's "no Route matched with those values") is now surfaced.
239+
expect(String(e)).toStrictEqual(
240+
'Error: HTTP error! status: 404 no Route matched with those values',
241+
);
239242

240243
// Error cause
241244
const cause = getErrorCause(e) as any;

src/relayer-provider/v2/errors/RelayerV2ResponseStatusError.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export type RelayerV2ResponseStatusErrorType = RelayerV2ResponseStatusError & {
1515
export type RelayerV2ResponseStatusErrorParams = Prettify<
1616
Omit<RelayerV2ResponseErrorBaseParams, keyof RelayerErrorBaseParams> & {
1717
state: RelayerV2AsyncRequestState;
18+
/**
19+
* Optional message provided by the relayer or an intermediary
20+
* (e.g. Cloudflare/Kong) for the unexpected status, surfaced as `Details:`.
21+
*/
22+
details?: string | undefined;
1823
}
1924
>;
2025

0 commit comments

Comments
 (0)