@deepdockai/cli 1.1.1 → 1.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.2
4
+
5
+ - The bundled TypeScript and Python clients wait 35 seconds per attempt (was 30): a call can wait up to 10 seconds for the device's slot and then up to 20 seconds on the device. They also retry `503 target_busy` up to three attempts (`maxAttempts` / `max_attempts`), waiting the `Retry-After` seconds (at most 15, one second without the header) and sending the same request, so a write keeps its idempotency key. `DeepdockError` carries the header as `retryAfter` / `retry_after`.
6
+
3
7
  ## 1.1.1
4
8
 
5
9
  - A new error code, `partner_deactivated` (403): Deepdock has deactivated the organisation, so none of its keys work and its devices are disconnected until Deepdock reactivates it. The CLI exits with 2 on it, as on `partner_not_approved`. The bundled device types and the skill's error reference include it.
package/dist/deepdock.js CHANGED
@@ -11441,7 +11441,7 @@ import { spawn } from "node:child_process";
11441
11441
  import { homedir, hostname } from "node:os";
11442
11442
 
11443
11443
  // src/version.ts
11444
- var VERSION = "1.1.1";
11444
+ var VERSION = "1.1.2";
11445
11445
 
11446
11446
  // src/runtime.ts
11447
11447
  var DEFAULT_BASE_URL = "https://api.deepdock.ai";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepdockai/cli",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Deepdock partner CLI: pair devices, see systems and actions, create the API key your code runs them with.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -114,8 +114,12 @@ Never invent an id, copy one from another device, or hard-code one from a test r
114
114
 
115
115
  - Every action call is sent to the customer's PC and run there. Expect a read to take from a few
116
116
  hundred milliseconds to a few seconds; a write about the same plus its verification.
117
- - Deepdock waits at most 20 seconds for the device, then answers `503 device_offline`. Set your HTTP
118
- client timeout to 30 seconds so you receive the code instead of timing out first. A write that
117
+ - A call can wait up to 10 seconds for the device's slot (`503 target_busy` with a `Retry-After`
118
+ header when it stays taken), then gets up to 20 seconds on the device (`503 device_offline`), so
119
+ the worst answer takes about 30 seconds. Set your HTTP client timeout to at least 35 seconds so
120
+ you receive the code instead of timing out first. The bundled clients do this and retry
121
+ `503 target_busy` up to three attempts, waiting the `Retry-After` seconds (at most 15), with the
122
+ same `Idempotency-Key`. A write that
119
123
  timed out or failed on the network on your side may still have landed: retry with the same
120
124
  `Idempotency-Key`.
121
125
  - An answer that is not JSON (a proxy's 502 or 504 page) is a transport failure: for a write, the
@@ -8,6 +8,7 @@ Everything action-specific (fields, types, patterns) comes from the live OpenAPI
8
8
  from __future__ import annotations
9
9
 
10
10
  import os
11
+ import time
11
12
  from dataclasses import dataclass, field, replace
12
13
  from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
13
14
  from urllib.parse import quote
@@ -33,9 +34,22 @@ class DeepdockError(Exception):
33
34
  self.version = version # system_version_unverified: the version the system last reported, None when it reported none
34
35
  self.reason = reason # system_version_unverified: unverified_version, unparseable or unreported
35
36
  self.created = created # applied_unverified, outcome_unknown with no rollback: {"chartId"} or None
37
+ self.retry_after: Optional[str] = None # the Retry-After header as sent (seconds), when the answer carried one
36
38
  self.uncertain = uncertain # outcome_unknown: True when the write may or may not have landed
37
39
 
38
40
 
41
+ MAX_RETRY_WAIT = 15.0 # longest wait between two attempts, whatever Retry-After asks for
42
+ DEFAULT_RETRY_WAIT = 1.0
43
+
44
+
45
+ def _retry_wait(header: Optional[str]) -> float:
46
+ try:
47
+ seconds = float(header) if header is not None else -1.0
48
+ except ValueError:
49
+ seconds = -1.0
50
+ return min(seconds, MAX_RETRY_WAIT) if seconds >= 0 else DEFAULT_RETRY_WAIT
51
+
52
+
39
53
  class DeepdockTransportError(Exception):
40
54
  """No answer, or not Deepdock's envelope (timeout, network, a proxy page): a write may still have landed."""
41
55
 
@@ -53,15 +67,31 @@ class WriteResult:
53
67
 
54
68
 
55
69
  class Deepdock:
56
- def __init__(self, api_key: str, base: Optional[str] = None, timeout: float = 30.0,
57
- client: Optional[httpx.Client] = None):
58
- # 30 s is above Deepdock's 20 s device wait, so device_offline arrives before a client timeout.
70
+ def __init__(self, api_key: str, base: Optional[str] = None, timeout: float = 35.0,
71
+ client: Optional[httpx.Client] = None, max_attempts: int = 3, sleep=time.sleep):
72
+ # 35 s is above the worst answer (up to 10 s for the device's slot, then up to 20 s on the
73
+ # device), so the error code arrives before a client timeout.
74
+ # max_attempts: attempts per call on 503 target_busy (1 turns the retry off). The same
75
+ # request is sent again, so a write keeps its idempotency key.
76
+ self.max_attempts = max(1, int(max_attempts))
77
+ self._sleep = sleep
59
78
  # Default: DEEPDOCK_BASE_URL from the environment (the CLI's own variable), else production.
60
79
  base = base or os.environ.get("DEEPDOCK_BASE_URL") or "https://api.deepdock.ai"
61
80
  self.c = client or httpx.Client(base_url=base.rstrip("/"), timeout=timeout)
62
81
  self.c.headers["Authorization"] = f"Bearer {api_key}"
63
82
 
64
83
  def _send(self, method: str, path: str, **kw: Any) -> Tuple[Any, httpx.Headers]:
84
+ attempt = 1
85
+ while True:
86
+ try:
87
+ return self._send_once(method, path, **kw)
88
+ except DeepdockError as e:
89
+ if attempt >= self.max_attempts or e.status != 503 or e.code != "target_busy":
90
+ raise
91
+ self._sleep(_retry_wait(e.retry_after))
92
+ attempt += 1
93
+
94
+ def _send_once(self, method: str, path: str, **kw: Any) -> Tuple[Any, httpx.Headers]:
65
95
  try:
66
96
  r = self.c.request(method, path, **kw)
67
97
  except httpx.HTTPError as e: # timeouts and network errors
@@ -72,8 +102,10 @@ class Deepdock:
72
102
  raise DeepdockTransportError(f"non-JSON answer with status {r.status_code}")
73
103
  if isinstance(body, dict) and "error" in body:
74
104
  e = body["error"]
75
- raise DeepdockError(r.status_code, e["code"], e["message"], e.get("requestId"), e.get("systems"),
105
+ err = DeepdockError(r.status_code, e["code"], e["message"], e.get("requestId"), e.get("systems"),
76
106
  e.get("version"), e.get("reason"), e.get("created"), e.get("uncertain"))
107
+ err.retry_after = r.headers.get("retry-after")
108
+ raise err
77
109
  if not isinstance(body, dict) or "data" not in body:
78
110
  raise DeepdockTransportError(f"unexpected body with status {r.status_code}")
79
111
  return body["data"], r.headers
@@ -21,6 +21,8 @@ export class DeepdockError extends Error {
21
21
  readonly created?: { chartId?: string } | null
22
22
  /** outcome_unknown: `true` when the write may or may not have landed. */
23
23
  readonly uncertain?: boolean
24
+ /** The `Retry-After` header as sent (seconds), when the answer carried one. */
25
+ retryAfter?: string
24
26
 
25
27
  constructor(status: number, code: string, message: string, requestId?: string,
26
28
  systems?: string[],
@@ -131,9 +133,24 @@ export type DeviceSystem = {
131
133
  export type DeepdockOptions = {
132
134
  /** Default: `DEEPDOCK_BASE_URL` from the environment (the CLI's own variable), else production. */
133
135
  base?: string
134
- /** Above Deepdock's 20 s device wait, so you receive device_offline instead of timing out first. */
136
+ /** Per attempt, default 35 s: above the worst answer (up to 10 s for the device's slot, then up to 20 s on the device), so you receive the code instead of timing out first. */
135
137
  timeoutMs?: number
138
+ /** Attempts per call on `503 target_busy`, default 3; 1 turns the retry off. The same request is sent again, so a write keeps its idempotency key. */
139
+ maxAttempts?: number
136
140
  fetch?: typeof fetch
141
+ /** Waits between attempts; replace it in tests. */
142
+ sleep?: (ms: number) => Promise<void>
143
+ }
144
+
145
+ /** Longest wait between two attempts, whatever `Retry-After` asks for. */
146
+ const MAX_RETRY_WAIT_MS = 15_000
147
+ const DEFAULT_RETRY_WAIT_MS = 1_000
148
+
149
+ const retryWaitMs = (header: string | null): number => {
150
+ const seconds = header === null ? Number.NaN : Number(header)
151
+ return Number.isFinite(seconds) && seconds >= 0
152
+ ? Math.min(seconds * 1000, MAX_RETRY_WAIT_MS)
153
+ : DEFAULT_RETRY_WAIT_MS
137
154
  }
138
155
 
139
156
  // `process` is absent in a browser, and Deno throws on `process.env` without --allow-env; then the
@@ -150,21 +167,52 @@ export class Deepdock {
150
167
  private readonly apiKey: string
151
168
  private readonly base: string
152
169
  private readonly timeoutMs: number
170
+ private readonly maxAttempts: number
153
171
  private readonly fetchImpl: typeof fetch
172
+ private readonly sleep: (ms: number) => Promise<void>
154
173
 
155
174
  constructor(
156
175
  apiKey: string,
157
- { base = envBase() ?? 'https://api.deepdock.ai', timeoutMs = 30_000, fetch: fetchImpl = fetch }: DeepdockOptions = {},
176
+ {
177
+ base = envBase() ?? 'https://api.deepdock.ai',
178
+ timeoutMs = 35_000,
179
+ maxAttempts = 3,
180
+ fetch: fetchImpl = fetch,
181
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
182
+ }: DeepdockOptions = {},
158
183
  ) {
159
184
  this.apiKey = apiKey
160
185
  this.base = base.replace(/\/$/, '')
161
186
  this.timeoutMs = timeoutMs
187
+ this.maxAttempts = Math.max(1, Math.floor(maxAttempts))
162
188
  this.fetchImpl = fetchImpl
189
+ this.sleep = sleep
163
190
  }
164
191
 
165
192
  private async send<T>(
166
193
  path: string,
167
194
  init: { method: string; headers?: Record<string, string>; body?: string },
195
+ ): Promise<{ data: T; headers: Headers }> {
196
+ for (let attempt = 1; ; attempt++) {
197
+ try {
198
+ return await this.sendOnce<T>(path, init)
199
+ } catch (err) {
200
+ if (
201
+ attempt >= this.maxAttempts ||
202
+ !(err instanceof DeepdockError) ||
203
+ err.status !== 503 ||
204
+ err.code !== 'target_busy'
205
+ ) {
206
+ throw err
207
+ }
208
+ await this.sleep(retryWaitMs(err.retryAfter ?? null))
209
+ }
210
+ }
211
+ }
212
+
213
+ private async sendOnce<T>(
214
+ path: string,
215
+ init: { method: string; headers?: Record<string, string>; body?: string },
168
216
  ): Promise<{ data: T; headers: Headers }> {
169
217
  let res: Response
170
218
  try {
@@ -186,7 +234,9 @@ export class Deepdock {
186
234
  }
187
235
  if (body !== null && typeof body === 'object' && 'error' in body) {
188
236
  const { code, message, requestId, systems, version, reason, created, uncertain } = body.error
189
- throw new DeepdockError(res.status, code, message, requestId, systems, version, reason, created, uncertain)
237
+ const error = new DeepdockError(res.status, code, message, requestId, systems, version, reason, created, uncertain)
238
+ error.retryAfter = res.headers.get('retry-after') ?? undefined
239
+ throw error
190
240
  }
191
241
  if (body === null || typeof body !== 'object' || !('data' in body)) {
192
242
  throw new DeepdockTransportError(`unexpected body with status ${res.status}`)
@@ -20,7 +20,7 @@ Copy the file into the project; do not reach into the skill directory at runtime
20
20
  ```ts
21
21
  import { Deepdock, DeepdockError, DeepdockTransportError, attempt, type PendingWrite } from './deepdock'
22
22
 
23
- const dd = new Deepdock(process.env.DEEPDOCK_API_KEY!) // base from DEEPDOCK_BASE_URL, else production; 30 s timeout
23
+ const dd = new Deepdock(process.env.DEEPDOCK_API_KEY!) // base from DEEPDOCK_BASE_URL, else production; 35 s timeout, retries 503 target_busy
24
24
  const page = await dd.read<Note>(deviceId, 'example-erp', 'searchNotes', { text: 'TEST-DEEPDOCK-' }, { limit: 50 })
25
25
  for await (const p of dd.readAll<Note>(deviceId, 'example-erp', 'searchNotes', { text: 'TEST-DEEPDOCK-' })) { /* every page */ }
26
26
  const { data, replayed } = await dd.write<{ noteId: number }>(deviceId, 'example-erp', 'createNote', 'create', key, input)
@@ -92,7 +92,7 @@ const res = await fetch(url, {
92
92
  'Content-Type': 'application/json',
93
93
  },
94
94
  body: JSON.stringify(input),
95
- signal: AbortSignal.timeout(30_000),
95
+ signal: AbortSignal.timeout(35_000),
96
96
  })
97
97
  const replayed = res.headers.get('Idempotent-Replayed') === 'true'
98
98
  ```