@shardflux/sdk 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { components as AppComponents } from './generated/app-api.js';
2
2
  import type { components as CellComponents } from './generated/cell-api.js';
3
- import type { LifecycleTiming } from './progress.js';
3
+ import type { Durability, LifecycleTiming } from './progress.js';
4
4
  export type AppErrorBody = AppComponents['schemas']['ErrorBody'];
5
5
  export type AppErrorCode = AppErrorBody['error']['code'];
6
6
  export type CellErrorCode = CellComponents['schemas']['ErrorCode'];
@@ -8,28 +8,28 @@ export type CellErrorCode = CellComponents['schemas']['ErrorCode'];
8
8
  export type ErrorCode = AppErrorCode | CellErrorCode;
9
9
  /**
10
10
  * `details.reason` values the SDK knows (the error code enum is closed; new cases add reasons). Templates v2
11
- * (contracts §19.14) added: 409 conflict legacy_disk_layout, not_session, session_lifetime, lifetime_mismatch,
11
+ * added: 409 conflict legacy_disk_layout, not_session, session_lifetime, lifetime_mismatch,
12
12
  * not_resettable, template_not_layered, draft_exists, draft_stale, build_in_progress, file_list_unavailable,
13
13
  * file_list_indexing (retryable), guest_feature_unavailable; 422 validation_failed confirm_destructive_required,
14
14
  * reserved_key_prefix, invalid_defaults, update_policy_not_available, invalid_path, too_many_acknowledged_findings;
15
15
  * 403 forbidden template_dev_mode_role; 404 not_found draft_not_found, version_not_found, path_not_found.
16
- * The template editor (contracts §24.6, 0.7.0) added: 422 validation_failed invalid_recipe, base_not_layered,
16
+ * The template editor (0.7.0) added: 422 validation_failed invalid_recipe, base_not_layered,
17
17
  * language_unavailable, language_conflict, invalid_package, too_many_files, platform_owned_path, upload_required,
18
18
  * upload_missing, upload_digest_mismatch, upload_too_large, extra_hosts_without_auto, invalid_settings,
19
19
  * services_unsupported, input_required, input_unknown, input_invalid, egress_widening, reserved_session_id,
20
20
  * env_collision, reserved_template_slug; 409 conflict package_index_unavailable; 404 package_not_found; the operation
21
21
  * error `startup_failed` (details.reason startup_failed, service_not_ready, secrets_unavailable, secret_not_available,
22
- * guest_feature_unavailable). File tools and parking (contracts §25-§28, 0.9.0) added: 409 conflict revision_mismatch
22
+ * guest_feature_unavailable). File tools and parking (0.9.0) added: 409 conflict revision_mismatch
23
23
  * (details.current_revision); 422 validation_failed edit_not_found, edit_ambiguous (details.index), edit_not_text,
24
24
  * patch_invalid; 503 service_unavailable host_capacity and wake_failed (both retryable, with Retry-After); 409
25
25
  * workspace_busy workspace_fenced (a lifecycle operation holds the workspace; waited out like any workspace_busy).
26
26
  * Reads of a sleeping workspace (§26.4/§26.5): 409 workspace_not_running offline_unavailable and offline_budget
27
27
  * (retryable; the disk could not be read offline: the workspace is woken and the call retried like any
28
28
  * workspace_not_running), 503 offline_changed (retryable; the retry is served by the guest).
29
- * Hosts without the features (contracts §26.7, 0.9.0): 409 conflict host_feature_unavailable (not retryable;
30
- * details.feature `file_search` or `file_patch`): the workspace runs on a host agent that predates the call, until it
31
- * runs on an upgraded host (minutes to hours); read and write the file, or run a search command, instead.
32
- * File-first workspaces (contracts §29.7, §29.8; 0.9.0) added: 409 conflict not_supported_for_mode (details.mode,
29
+ * Calls not available for a workspace (0.9.0): 409 conflict host_feature_unavailable (not retryable; details.feature
30
+ * `file_search` or `file_patch`): the call is not available for this workspace; use the fallback in the hint (read and
31
+ * write the file, or run a search command).
32
+ * File-first workspaces (0.9.0) added: 409 conflict not_supported_for_mode (details.mode,
33
33
  * details.operation; NotSupportedForModeError), mode_mismatch (reopening a key with another mode), layout_unsupported (a
34
34
  * legacy template), tree_revision_mismatch (details.current_tree_revision; TreeRevisionMismatchError),
35
35
  * execution_id_reused, operation_id_reused (an Idempotency-Key reused for another request); 422 validation_failed
@@ -66,7 +66,7 @@ export interface ErrorBodyLike {
66
66
  };
67
67
  }
68
68
  export declare function isErrorBody(v: unknown): v is ErrorBodyLike;
69
- /** A structured error from the application API or a cell gateway (contracts §3). */
69
+ /** A structured error from the application API or a cell gateway. */
70
70
  export declare class ShardfluxApiError extends Error {
71
71
  readonly status: number;
72
72
  readonly code: ErrorCode;
@@ -81,16 +81,16 @@ export declare class ShardfluxApiError extends Error {
81
81
  /** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
82
82
  timing: LifecycleTiming | undefined;
83
83
  /**
84
- * `X-Tree-Revision` of the refusal (file-first workspaces, contracts §29.8): the tree revision the refused call saw.
84
+ * `X-Tree-Revision` of the refusal (file-first workspaces): the tree revision the refused call saw.
85
85
  * Undefined when the response carried none (processful workspaces, the application API).
86
86
  */
87
87
  readonly treeRevision: number | undefined;
88
88
  constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
89
89
  }
90
- /** processful (the default: one VM keeps processes, memory and files) or file_first (contracts §29). */
90
+ /** processful (the default: one VM keeps processes, memory and files) or file_first. */
91
91
  export type WorkspaceMode = AppComponents['schemas']['WorkspaceMode'];
92
92
  /**
93
- * The call does not exist for the workspace's mode (contracts §29.7, §29.8): 409 `conflict` with details.reason
93
+ * The call does not exist for the workspace's mode: 409 `conflict` with details.reason
94
94
  * `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
95
95
  * VM between executions, so exec sessions, PTY, processes, version control, browser, changes, suspend, resume,
96
96
  * snapshot, fork, reset, save-as-template, volumes and idle policies are refused; processful workspaces have no
@@ -109,7 +109,7 @@ export declare class NotSupportedForModeError extends ShardfluxApiError {
109
109
  static local(mode: WorkspaceMode, operation: string, source: 'api' | 'cell', message: string): NotSupportedForModeError;
110
110
  }
111
111
  /**
112
- * A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision (contracts §29.8): 409
112
+ * A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision: 409
113
113
  * `conflict`, details.reason `tree_revision_mismatch`. Nothing changed. `currentTreeRevision` is the revision the tree
114
114
  * is at: read what changed, then retry against it.
115
115
  */
@@ -141,14 +141,18 @@ export declare function apiError(status: number, body: ErrorBodyLike, source: 'a
141
141
  /** The response was not the documented shape (e.g. a proxy error page). */
142
142
  export declare class ShardfluxProtocolError extends Error {
143
143
  readonly status: number;
144
- constructor(message: string, status: number);
144
+ /** The responding surface; unknown only for errors constructed by older callers. Never proof of deletion. */
145
+ readonly source: 'api' | 'cell' | 'unknown';
146
+ constructor(message: string, status: number, source?: 'api' | 'cell' | 'unknown');
145
147
  }
148
+ /** True only for an explicit API tombstone refusal. A 404, even a structured one, may be routing or scope. */
149
+ export declare function isWorkspaceGone(error: unknown): boolean;
146
150
  type Operation = AppComponents['schemas']['Operation'];
147
151
  /**
148
152
  * Waiting for an operation ran out of time. The operation keeps running server side:
149
153
  * resume with `cloud.workspaces.waitForOperation(err.operationId)` or call open() again
150
- * (it returns the same operation while it is active). A start still waiting for capacity
151
- * (`capacity_pending`) gives up at `deadlineAt` and then fails with `capacity_unavailable`.
154
+ * (it returns the same operation while it is active). A queued start (`capacity_pending`) that
155
+ * passes its deadline (`deadlineAt`) fails with `capacity_unavailable`; nothing was started.
152
156
  */
153
157
  export declare class OperationTimeoutError extends Error {
154
158
  readonly operationId: string;
@@ -156,28 +160,53 @@ export declare class OperationTimeoutError extends Error {
156
160
  readonly lastState: Operation['state'];
157
161
  readonly lastReason: string | null;
158
162
  /**
159
- * When the operation was last `capacity_pending`: when it gives up waiting for a host (`error.details.deadline_at`,
160
- * RFC 3339) and fails with `capacity_unavailable`. Null in other states or from an API that does not report it.
163
+ * When the operation was last `capacity_pending`: its deadline (`error.details.deadline_at`, RFC 3339), past which it
164
+ * fails with `capacity_unavailable`. Null in other states or from an API that does not report it.
161
165
  */
162
166
  readonly deadlineAt: string | null;
163
167
  readonly waitedMs: number;
164
168
  /** Where the time went: client phases, retries and the operation's own server timing so far. */
165
169
  timing: LifecycleTiming | undefined;
166
- constructor(op: Operation, waitedMs: number);
170
+ /**
171
+ * `durable` (0.12.0+): the wait was for the durable copy of a succeeded suspend or fork (`suspend({ durable: true })`,
172
+ * `waitForDurable()`); `lastState` is then `succeeded` and the copy continues server side.
173
+ */
174
+ readonly durable: boolean;
175
+ constructor(op: Operation, waitedMs: number, durable?: boolean);
167
176
  }
168
- /** The operation reached `failed` or `canceled`. */
177
+ /**
178
+ * The operation reached `failed` or `canceled`. A suspend-when-idle that found the workspace active (a tool call after
179
+ * the request, an attached stream) is `canceled` with `errorCode` `workspace_active` (`workspaceActive` true, 0.12.0+):
180
+ * nothing changed and the workspace keeps running. `waitUntilReady()` and `wake()` treat it as running, not as a failure.
181
+ */
169
182
  export declare class OperationFailedError extends Error {
170
183
  readonly operation: Operation;
171
184
  readonly operationId: string;
172
185
  readonly errorCode: string | null;
186
+ /** 0.12.0+: a suspend-when-idle canceled because the workspace was active; it keeps running, nothing changed. */
187
+ readonly workspaceActive: boolean;
173
188
  /**
174
189
  * `operation.error.retryable` (false when absent): the same request may succeed later. `capacity_unavailable` is
175
- * retryable: no host could admit the start before its deadline, nothing was started, retry later. The SDK never
176
- * retries a failed operation itself.
190
+ * retryable: the start passed its deadline and nothing was started; send it again. The SDK never retries a failed
191
+ * operation itself.
177
192
  */
178
193
  readonly retryable: boolean;
179
194
  /** Where the time went before the operation failed. */
180
195
  timing: LifecycleTiming | undefined;
181
196
  constructor(op: Operation);
182
197
  }
198
+ /**
199
+ * `suspend({ durable: true })` or `waitForDurable()` (0.12.0+): the suspend (or fork) succeeded, but its durable copy
200
+ * could not be made (`durability.state` `lost`). `durability` has the reason; see the lifecycle reference for what the
201
+ * next resume restores.
202
+ */
203
+ export declare class DurabilityLostError extends Error {
204
+ readonly operation: Operation;
205
+ readonly operationId: string;
206
+ readonly workspaceId: string | null;
207
+ readonly durability: Durability;
208
+ /** Where the time went. */
209
+ timing: LifecycleTiming | undefined;
210
+ constructor(op: Operation);
211
+ }
183
212
  export {};
package/dist/errors.js CHANGED
@@ -1,11 +1,11 @@
1
- import { capacityDeadlineOf } from "./progress.js";
1
+ import { capacityDeadlineOf, durabilityOf } from "./progress.js";
2
2
  export function isErrorBody(v) {
3
3
  if (typeof v !== 'object' || v === null || !('error' in v))
4
4
  return false;
5
5
  const e = v.error;
6
6
  return typeof e === 'object' && e !== null && typeof e.code === 'string' && typeof e.message === 'string';
7
7
  }
8
- /** A structured error from the application API or a cell gateway (contracts §3). */
8
+ /** A structured error from the application API or a cell gateway. */
9
9
  export class ShardfluxApiError extends Error {
10
10
  status;
11
11
  code;
@@ -20,7 +20,7 @@ export class ShardfluxApiError extends Error {
20
20
  /** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
21
21
  timing = undefined;
22
22
  /**
23
- * `X-Tree-Revision` of the refusal (file-first workspaces, contracts §29.8): the tree revision the refused call saw.
23
+ * `X-Tree-Revision` of the refusal (file-first workspaces): the tree revision the refused call saw.
24
24
  * Undefined when the response carried none (processful workspaces, the application API).
25
25
  */
26
26
  treeRevision;
@@ -41,7 +41,7 @@ export class ShardfluxApiError extends Error {
41
41
  }
42
42
  }
43
43
  /**
44
- * The call does not exist for the workspace's mode (contracts §29.7, §29.8): 409 `conflict` with details.reason
44
+ * The call does not exist for the workspace's mode: 409 `conflict` with details.reason
45
45
  * `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
46
46
  * VM between executions, so exec sessions, PTY, processes, version control, browser, changes, suspend, resume,
47
47
  * snapshot, fork, reset, save-as-template, volumes and idle policies are refused; processful workspaces have no
@@ -70,7 +70,7 @@ export class NotSupportedForModeError extends ShardfluxApiError {
70
70
  }
71
71
  }
72
72
  /**
73
- * A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision (contracts §29.8): 409
73
+ * A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision: 409
74
74
  * `conflict`, details.reason `tree_revision_mismatch`. Nothing changed. `currentTreeRevision` is the revision the tree
75
75
  * is at: read what changed, then retry against it.
76
76
  */
@@ -129,17 +129,25 @@ export function apiError(status, body, source, retryAfterSeconds, treeRevision)
129
129
  /** The response was not the documented shape (e.g. a proxy error page). */
130
130
  export class ShardfluxProtocolError extends Error {
131
131
  status;
132
- constructor(message, status) {
132
+ /** The responding surface; unknown only for errors constructed by older callers. Never proof of deletion. */
133
+ source;
134
+ constructor(message, status, source = 'unknown') {
133
135
  super(message);
134
136
  this.name = 'ShardfluxProtocolError';
135
137
  this.status = status;
138
+ this.source = source;
136
139
  }
137
140
  }
141
+ /** True only for an explicit API tombstone refusal. A 404, even a structured one, may be routing or scope. */
142
+ export function isWorkspaceGone(error) {
143
+ return error instanceof ShardfluxApiError && error.source === 'api' &&
144
+ error.code === 'conflict' && error.reason === 'workspace_deleted';
145
+ }
138
146
  /**
139
147
  * Waiting for an operation ran out of time. The operation keeps running server side:
140
148
  * resume with `cloud.workspaces.waitForOperation(err.operationId)` or call open() again
141
- * (it returns the same operation while it is active). A start still waiting for capacity
142
- * (`capacity_pending`) gives up at `deadlineAt` and then fails with `capacity_unavailable`.
149
+ * (it returns the same operation while it is active). A queued start (`capacity_pending`) that
150
+ * passes its deadline (`deadlineAt`) fails with `capacity_unavailable`; nothing was started.
143
151
  */
144
152
  export class OperationTimeoutError extends Error {
145
153
  operationId;
@@ -147,17 +155,25 @@ export class OperationTimeoutError extends Error {
147
155
  lastState;
148
156
  lastReason;
149
157
  /**
150
- * When the operation was last `capacity_pending`: when it gives up waiting for a host (`error.details.deadline_at`,
151
- * RFC 3339) and fails with `capacity_unavailable`. Null in other states or from an API that does not report it.
158
+ * When the operation was last `capacity_pending`: its deadline (`error.details.deadline_at`, RFC 3339), past which it
159
+ * fails with `capacity_unavailable`. Null in other states or from an API that does not report it.
152
160
  */
153
161
  deadlineAt;
154
162
  waitedMs;
155
163
  /** Where the time went: client phases, retries and the operation's own server timing so far. */
156
164
  timing = undefined;
157
- constructor(op, waitedMs) {
165
+ /**
166
+ * `durable` (0.12.0+): the wait was for the durable copy of a succeeded suspend or fork (`suspend({ durable: true })`,
167
+ * `waitForDurable()`); `lastState` is then `succeeded` and the copy continues server side.
168
+ */
169
+ durable;
170
+ constructor(op, waitedMs, durable = false) {
158
171
  const deadlineAt = capacityDeadlineOf(op);
159
- const after = deadlineAt ? `it keeps waiting for a host server side until ${deadlineAt} and fails with capacity_unavailable if none admits it by then.` : 'it continues server side.';
160
- super(`Operation ${op.id} (${op.kind}) is still ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''} after ${Math.round(waitedMs)} ms; ${after}`);
172
+ const after = deadlineAt ? `it stays queued server side until ${deadlineAt} and fails with capacity_unavailable if it has not started by then.` : 'it continues server side.';
173
+ super(durable
174
+ ? `Operation ${op.id} (${op.kind}) succeeded; its durable copy was still ${durabilityOf(op)?.state ?? 'pending'} after ${Math.round(waitedMs)} ms and continues server side.`
175
+ : `Operation ${op.id} (${op.kind}) is still ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''} after ${Math.round(waitedMs)} ms; ${after}`);
176
+ this.durable = durable;
161
177
  this.name = 'OperationTimeoutError';
162
178
  this.operationId = op.id;
163
179
  this.workspaceId = op.workspace_id;
@@ -167,15 +183,21 @@ export class OperationTimeoutError extends Error {
167
183
  this.waitedMs = waitedMs;
168
184
  }
169
185
  }
170
- /** The operation reached `failed` or `canceled`. */
186
+ /**
187
+ * The operation reached `failed` or `canceled`. A suspend-when-idle that found the workspace active (a tool call after
188
+ * the request, an attached stream) is `canceled` with `errorCode` `workspace_active` (`workspaceActive` true, 0.12.0+):
189
+ * nothing changed and the workspace keeps running. `waitUntilReady()` and `wake()` treat it as running, not as a failure.
190
+ */
171
191
  export class OperationFailedError extends Error {
172
192
  operation;
173
193
  operationId;
174
194
  errorCode;
195
+ /** 0.12.0+: a suspend-when-idle canceled because the workspace was active; it keeps running, nothing changed. */
196
+ workspaceActive;
175
197
  /**
176
198
  * `operation.error.retryable` (false when absent): the same request may succeed later. `capacity_unavailable` is
177
- * retryable: no host could admit the start before its deadline, nothing was started, retry later. The SDK never
178
- * retries a failed operation itself.
199
+ * retryable: the start passed its deadline and nothing was started; send it again. The SDK never retries a failed
200
+ * operation itself.
179
201
  */
180
202
  retryable;
181
203
  /** Where the time went before the operation failed. */
@@ -187,6 +209,29 @@ export class OperationFailedError extends Error {
187
209
  this.operation = op;
188
210
  this.operationId = op.id;
189
211
  this.errorCode = code;
212
+ this.workspaceActive = code === 'workspace_active' && op.kind === 'suspend';
190
213
  this.retryable = op.error?.retryable === true;
191
214
  }
192
215
  }
216
+ /**
217
+ * `suspend({ durable: true })` or `waitForDurable()` (0.12.0+): the suspend (or fork) succeeded, but its durable copy
218
+ * could not be made (`durability.state` `lost`). `durability` has the reason; see the lifecycle reference for what the
219
+ * next resume restores.
220
+ */
221
+ export class DurabilityLostError extends Error {
222
+ operation;
223
+ operationId;
224
+ workspaceId;
225
+ durability;
226
+ /** Where the time went. */
227
+ timing = undefined;
228
+ constructor(op) {
229
+ const durability = durabilityOf(op) ?? { state: 'lost', checkpointId: null, generationId: null, localCommitAt: null, durableBy: null, durableAt: null, localCommitToDurableMs: null, overdueAt: null, reason: null };
230
+ super(`Operation ${op.id} (${op.kind}): the durable copy of checkpoint ${durability.checkpointId ?? '?'} was not made${durability.reason ? ` (${durability.reason})` : ''}`);
231
+ this.name = 'DurabilityLostError';
232
+ this.operation = op;
233
+ this.operationId = op.id;
234
+ this.workspaceId = op.workspace_id;
235
+ this.durability = durability;
236
+ }
237
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Executions of file-first workspaces (contracts §29.1, §29.8): each `POST /exec` runs one command in a fresh VM on the
2
+ * Executions of file-first workspaces: each `POST /exec` runs one command in a fresh VM on the
3
3
  * workspace's latest tree revision and answers when it ended; the files it changed under /home/user become the next
4
4
  * revision. The execution id is the idempotency key: a request retried with the same id returns the recorded result
5
5
  * (or waits for the running execution) and never runs the command a second time.
@@ -46,7 +46,7 @@ export interface ExecutionRunOptions {
46
46
  outputLimitBytes?: number;
47
47
  /**
48
48
  * Retries, with the same execution id, of network failures and retryable 429/5xx answers (e.g. 503
49
- * `no_execution_host`: no host has room; `Retry-After` is honoured up to 30 s). Default 5. Refusals such as
49
+ * `no_execution_host`: the execution cannot be placed right now; `Retry-After` is honoured up to 30 s). Default 5. Refusals such as
50
50
  * `execution_id_reused` or a validation error are not retried; `workspace_busy` (another execution holds the
51
51
  * workspace) is waited out within the client's `transitionTimeoutMs` like any tool call.
52
52
  */
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Product feedback (POST /v1/feedback, 0.9.0+): a short message from the customer or their coding agent that goes
3
- * straight to the Shardflux founder by email.
3
+ * straight to the Shardflux team by email.
4
4
  *
5
- * await cloud.sendFeedback({ message: 'open took 40 s on python-node-browser', category: 'bug', context: { requestId: err.requestId } });
5
+ * await cloud.sendFeedback({ message: 'open rejected template "python-node" with template_not_found; expected a suggestion of the closest slug', category: 'bug', context: { requestId: err.requestId } });
6
6
  *
7
7
  * Any valid API key may send (no tool permission needed). The server bounds it per key (429 `rate_limited`,
8
8
  * `retryAfterSeconds` on the error; the SDK never retries it) and folds a repeat of the same message within 24 hours
@@ -23,7 +23,7 @@ export type FeedbackCategory = 'bug' | 'confusing' | 'missing' | 'idea' | 'prais
23
23
  export declare const FEEDBACK_CATEGORIES: readonly FeedbackCategory[];
24
24
  /** Longest message the API accepts, in characters after trimming (it must also contain a non-whitespace character). */
25
25
  export declare const FEEDBACK_MESSAGE_MAX_LENGTH = 8000;
26
- /** What the feedback is about, so the founder can find the logs. Every field is optional text. */
26
+ /** What the feedback is about, so the team can find the logs. Every field is optional text. */
27
27
  export interface FeedbackContext {
28
28
  /** Who is reporting: e.g. `claude-code`, `codex`, `cursor`, or a person (<= 100 characters). */
29
29
  agent?: string;
package/dist/feedback.js CHANGED
@@ -33,7 +33,7 @@ function feedbackBody(params) {
33
33
  export async function sendFeedback(ctx, params) {
34
34
  const { status, body } = await ctx.http.jsonWithStatus('POST', '/v1/feedback', { json: feedbackBody(params) }, ctx.authorization);
35
35
  if (typeof body?.id !== 'string' || typeof body.received_at !== 'string' || typeof body.duplicate !== 'boolean') {
36
- throw new ShardfluxProtocolError('POST /v1/feedback: the response is not {id, received_at, duplicate}', status);
36
+ throw new ShardfluxProtocolError('POST /v1/feedback: the response is not {id, received_at, duplicate}', status, 'api');
37
37
  }
38
38
  return { id: body.id, receivedAt: body.received_at, duplicate: body.duplicate };
39
39
  }