@cowliss/sdk 0.1.1 → 0.3.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/README.md CHANGED
@@ -48,8 +48,16 @@ per call when a single process writes into more than one app.
48
48
  Retries are handled for you. A call that fails to reach Cowliss, or that
49
49
  Cowliss could not complete, backs off and tries twice more; a generated
50
50
  `messageId` travels with every track, so a retry after a lost response never
51
- writes the event twice. A call Cowliss rejected is not retried: it throws a
52
- `CowError` carrying the reason.
51
+ writes the event twice. Each attempt has a deadline of its own (`timeoutMs`,
52
+ ten seconds by default, thirty for a batch), so a stalled Cowliss cannot
53
+ hold up your code. A
54
+ call Cowliss rejected is not retried: it throws a `CowError` carrying the
55
+ reason.
56
+
57
+ You do not have to wait for a call. `await cow.track(...)` throws on failure,
58
+ as it always has; `cow.track(...)` on its own returns immediately and can
59
+ never take your process down with an unhandled rejection. Pass `onError` to
60
+ see the failures a call you did not await would otherwise swallow.
53
61
 
54
62
  ## Docs
55
63
 
package/dist/index.d.ts CHANGED
@@ -12,6 +12,15 @@ export type * from "./types.js";
12
12
  * response dedupes instead of double-writing); network errors and 5xx retry with exponential backoff;
13
13
  * 4xx surface as typed CowErrors with the API's error-code enum, never
14
14
  * retried.
15
+ *
16
+ * Calling Cowliss must never take the caller's app with it, so the client is
17
+ * safe to use without awaiting. Every attempt carries a `timeoutMs` deadline
18
+ * (10s by default, 30s for a batch) so a stalled request cannot hang a
19
+ * caller for undici's
20
+ * multi-minute default, and every call marks its own rejection handled, so a
21
+ * deliberate fire-and-forget `cow.track(...)` cannot crash the process with
22
+ * an unhandled rejection. Awaiting still throws the CowError, and `onError`
23
+ * gives the fire-and-forget caller somewhere to see failures.
15
24
  */
16
25
  export declare class CowError extends Error {
17
26
  readonly code: ErrorCode | "network_error";
@@ -35,6 +44,27 @@ export type CowOptions = {
35
44
  baseUrl?: string;
36
45
  /** Retries after the initial attempt on network errors / 5xx. Default 2. */
37
46
  maxRetries?: number;
47
+ /**
48
+ * Deadline for one identify or track attempt, in milliseconds. Default
49
+ * 10000, so a call that exhausts the default retries gives up after ~30s
50
+ * instead of inheriting the runtime's multi-minute default. A timed-out
51
+ * attempt retries and finally surfaces as a `network_error` CowError.
52
+ */
53
+ timeoutMs?: number;
54
+ /**
55
+ * The same, for one `batch` attempt. Default 30000: a batch carries up to
56
+ * `MAX_BATCH_ITEMS` rows, so it is a slower request by design and holding
57
+ * it to the single-write deadline would abort imports that are finishing
58
+ * fine. Raise it for a large or slow import.
59
+ */
60
+ batchTimeoutMs?: number;
61
+ /**
62
+ * Called with the CowError of any failed call. Only useful to a caller who
63
+ * does not await, since an awaited call throws the same error; this is
64
+ * where a fire-and-forget `cow.track(...)` gets logged. Anything it throws
65
+ * is swallowed.
66
+ */
67
+ onError?: (error: CowError) => void;
38
68
  /** Injectable for tests and non-fetch runtimes. */
39
69
  fetch?: typeof globalThis.fetch;
40
70
  };
@@ -55,7 +85,7 @@ export declare class Cow {
55
85
  /**
56
86
  * Tracks one event. A messageId is generated when the caller does not
57
87
  * supply one, and sent both in the body and as the Idempotency-Key, so a
58
- * retried request (network error, 5xx, or lost response) dedupes
88
+ * retried request (network error, timeout, 5xx, or lost response) dedupes
59
89
  * server-side. Historical timestamps pass through for backfill scripts.
60
90
  */
61
91
  track(input: WithDefaultSource<TrackInput>): Promise<EventDto>;
package/dist/index.js CHANGED
@@ -13,6 +13,15 @@ const CLIENT_ID = `@cowliss/sdk/${SDK_VERSION}`;
13
13
  * response dedupes instead of double-writing); network errors and 5xx retry with exponential backoff;
14
14
  * 4xx surface as typed CowErrors with the API's error-code enum, never
15
15
  * retried.
16
+ *
17
+ * Calling Cowliss must never take the caller's app with it, so the client is
18
+ * safe to use without awaiting. Every attempt carries a `timeoutMs` deadline
19
+ * (10s by default, 30s for a batch) so a stalled request cannot hang a
20
+ * caller for undici's
21
+ * multi-minute default, and every call marks its own rejection handled, so a
22
+ * deliberate fire-and-forget `cow.track(...)` cannot crash the process with
23
+ * an unhandled rejection. Awaiting still throws the CowError, and `onError`
24
+ * gives the fire-and-forget caller somewhere to see failures.
16
25
  */
17
26
  export class CowError extends Error {
18
27
  code;
@@ -25,18 +34,28 @@ export class CowError extends Error {
25
34
  }
26
35
  }
27
36
  const RETRY_BASE_DELAY_MS = 100;
37
+ /** See {@link CowOptions.timeoutMs}. */
38
+ const DEFAULT_TIMEOUT_MS = 10_000;
39
+ /** See {@link CowOptions.batchTimeoutMs}. */
40
+ const DEFAULT_BATCH_TIMEOUT_MS = 30_000;
28
41
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
42
  export class Cow {
30
43
  #apiKey;
31
44
  #sourceId;
32
45
  #baseUrl;
33
46
  #maxRetries;
47
+ #timeoutMs;
48
+ #batchTimeoutMs;
49
+ #onError;
34
50
  #fetch;
35
51
  constructor(options) {
36
52
  this.#apiKey = options.apiKey;
37
53
  this.#sourceId = options.sourceId;
38
54
  this.#baseUrl = options.baseUrl ?? "http://localhost:3400";
39
55
  this.#maxRetries = options.maxRetries ?? 2;
56
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
57
+ this.#batchTimeoutMs = options.batchTimeoutMs ?? DEFAULT_BATCH_TIMEOUT_MS;
58
+ this.#onError = options.onError;
40
59
  this.#fetch = options.fetch ?? globalThis.fetch;
41
60
  }
42
61
  /**
@@ -51,20 +70,46 @@ export class Cow {
51
70
  }
52
71
  return sourceId;
53
72
  }
54
- async identify(input) {
55
- return this.#post("/v1/identify", {
56
- data: { ...input, sourceId: this.#sourceFor(input) },
73
+ /**
74
+ * Runs one call and marks its rejection handled, so a caller who
75
+ * deliberately does not await (`cow.track(...)` on a request path) can
76
+ * never take the process down with an unhandled rejection. The promise
77
+ * handed back is the original one, so awaiting still throws the CowError.
78
+ *
79
+ * This wraps at the public method, not inside `#post`, because
80
+ * `#sourceFor` throws before a request is ever made. The public methods
81
+ * are deliberately not `async` for the same reason: an `async` wrapper
82
+ * would hand the caller a second, unwatched promise and void the
83
+ * guarantee.
84
+ */
85
+ #watch(run) {
86
+ const promise = run();
87
+ promise.catch((error) => {
88
+ try {
89
+ this.#onError?.(error);
90
+ }
91
+ catch {
92
+ // An onError that throws must not become the crash we just prevented.
93
+ }
57
94
  });
95
+ return promise;
96
+ }
97
+ identify(input) {
98
+ return this.#watch(async () => this.#post("/v1/identify", {
99
+ data: { ...input, sourceId: this.#sourceFor(input) },
100
+ }));
58
101
  }
59
102
  /**
60
103
  * Tracks one event. A messageId is generated when the caller does not
61
104
  * supply one, and sent both in the body and as the Idempotency-Key, so a
62
- * retried request (network error, 5xx, or lost response) dedupes
105
+ * retried request (network error, timeout, 5xx, or lost response) dedupes
63
106
  * server-side. Historical timestamps pass through for backfill scripts.
64
107
  */
65
- async track(input) {
66
- const messageId = input.messageId ?? crypto.randomUUID();
67
- return this.#post("/v1/track", { data: { ...input, sourceId: this.#sourceFor(input), messageId } }, messageId);
108
+ track(input) {
109
+ return this.#watch(async () => {
110
+ const messageId = input.messageId ?? crypto.randomUUID();
111
+ return this.#post("/v1/track", { data: { ...input, sourceId: this.#sourceFor(input), messageId } }, messageId);
112
+ });
68
113
  }
69
114
  /**
70
115
  * Batch import: one source's identify and track calls in one request.
@@ -72,12 +117,10 @@ export class Cow {
72
117
  * the SDK does not generate one here, so re-running the same backfill
73
118
  * (same messageIds) dedupes instead of double-writing.
74
119
  */
75
- async batch(input) {
76
- return this.#post("/v1/batch", {
77
- data: { ...input, sourceId: this.#sourceFor(input) },
78
- });
120
+ batch(input) {
121
+ return this.#watch(async () => this.#post("/v1/batch", { data: { ...input, sourceId: this.#sourceFor(input) } }, undefined, this.#batchTimeoutMs));
79
122
  }
80
- async #post(path, body, idempotencyKey) {
123
+ async #post(path, body, idempotencyKey, timeoutMs = this.#timeoutMs) {
81
124
  for (let attempt = 0;; attempt++) {
82
125
  let res;
83
126
  try {
@@ -90,7 +133,15 @@ export class Cow {
90
133
  ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
91
134
  },
92
135
  body: JSON.stringify(body),
136
+ signal: AbortSignal.timeout(timeoutMs),
93
137
  });
138
+ // Reading the body is part of the attempt: an abort or a truncated
139
+ // response mid-read is a transport failure like any other, and
140
+ // retrying it is safe (track carries its Idempotency-Key, identify
141
+ // upserts on identifiers).
142
+ if (res.ok) {
143
+ return (await res.json()).data;
144
+ }
94
145
  }
95
146
  catch (cause) {
96
147
  if (attempt < this.#maxRetries) {
@@ -99,9 +150,6 @@ export class Cow {
99
150
  }
100
151
  throw new CowError("network_error", "Network error", 0, { cause });
101
152
  }
102
- if (res.ok) {
103
- return (await res.json()).data;
104
- }
105
153
  if (res.status >= 500 && attempt < this.#maxRetries) {
106
154
  await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
107
155
  continue;
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "0.1.1";
1
+ export declare const SDK_VERSION = "0.3.0";
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/version.ts from package.json. Do not edit.
2
- export const SDK_VERSION = "0.1.1";
2
+ export const SDK_VERSION = "0.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowliss/sdk",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {