Skip to content

Commit d4daeb7

Browse files
committed
feat: add fallbacks
1 parent 32e2184 commit d4daeb7

4 files changed

Lines changed: 345 additions & 157 deletions

File tree

README.md

Lines changed: 42 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ Part of the Replane project: [tilyupo/replane](https://github.com/tilyupo/replan
1010

1111
You just need: given a token + config name -> get the value. This package does only that, well:
1212

13-
- Single focused call: `getConfig(name)`
1413
- Works in ESM and CJS (dual build)
1514
- Zero runtime deps (uses native `fetch` — bring a polyfill if your runtime lacks it)
1615
- Tiny bundle footprint
17-
- Strong TypeScript types + custom error with status/body
16+
- Strong TypeScript types
17+
- Resilient to server errors (returns your fallback and logs)
1818

1919
## Installation
2020

@@ -36,7 +36,10 @@ const client = createReplaneClient({
3636
baseUrl: "https://api.my-replane-host.com",
3737
});
3838

39-
const featureFlag = await client.getConfig<boolean>("new-onboarding");
39+
const featureFlag = await client.getConfig<boolean>({
40+
name: "new-onboarding",
41+
fallback: false,
42+
});
4043

4144
// or a more complex example
4245

@@ -45,40 +48,41 @@ interface PasswordRequirements {
4548
requireSymbol: boolean;
4649
}
4750

48-
const passwordRequirements = await client.getConfig<PasswordRequirements>(
49-
"password-requirements"
50-
);
51+
const passwordRequirements = await client.getConfig<PasswordRequirements>({
52+
name: "password-requirements",
53+
fallback: { minLength: 8, requireSymbol: false },
54+
});
5155
```
5256

5357
## API
5458

55-
### `createReplaneClient(token, options?)`
59+
### `createReplaneClient(options)`
5660

5761
Returns an object: `{ getConfig }`.
5862

5963
#### Options
6064

61-
- `baseUrl` (string) – API origin.
62-
- `apiKey` (string) - API key for authorization.
65+
- `baseUrl` (string) – API origin (no trailing slash needed).
66+
- `apiKey` (string) API key for authorization. Required.
6367
- `fetchFn` (function) – custom fetch (e.g. `undici.fetch` or mocked fetch in tests).
64-
- `timeoutMs` (number) – abort the request after N ms.
68+
- `timeoutMs` (number) – abort the request after N ms. Default: 1000.
69+
- `logger` (`{ info(...), error(...) }`) – optional logger (defaults to `console`).
6570

66-
### `client.getConfig(name, perCallOptions?)`
71+
### `client.getConfig({ name, fallback, ...overrides })`
6772

68-
Per‑call options accept the same keys (`baseUrl`, `apiKey`, `fetchFn`, `timeoutMs`) and override client defaults.
73+
Parameters:
6974

70-
Returns: the config value parsed as JSON.
75+
- `name` (string) – config name to fetch.
76+
- `fallback` (any) – value returned when request fails or response is invalid.
77+
- Overrides: `baseUrl`, `apiKey`, `fetchFn`, `timeoutMs`, `logger` – same semantics as in `createReplaneClient`.
7178

72-
### Errors: `ReplaneError`
79+
Returns: the config value.
7380

74-
Thrown when the HTTP status is not 2xx. Shape:
81+
Failures (non-2xx, network error, or invalid JSON) do not throw; the function logs via `logger.error(...)` and returns your `fallback`.
7582

76-
```ts
77-
class ReplaneError extends Error {
78-
status: number;
79-
body: unknown; // parsed JSON or text when possible
80-
}
81-
```
83+
### Errors
84+
85+
This SDK doesn't throw on request/response errors during `getConfig`. Instead, it logs (using the provided or default logger) and returns the provided `fallback`.
8286

8387
## Environment notes
8488

@@ -94,47 +98,36 @@ interface LayoutConfig {
9498
variant: "a" | "b";
9599
ttl: number;
96100
}
97-
const layout = await client.getConfig<LayoutConfig>("layout");
101+
const layout = await client.getConfig<LayoutConfig>({
102+
name: "layout",
103+
fallback: { variant: "a", ttl: 0 },
104+
});
98105
```
99106

100107
Timeout override:
101108

102109
```ts
103-
await client.getConfig("slow-config", { timeoutMs: 1500 });
110+
await client.getConfig({
111+
name: "slow-config",
112+
fallback: null,
113+
timeoutMs: 1500,
114+
});
104115
```
105116

106117
Custom fetch (tests):
107118

108119
```ts
109-
const client = createReplaneClient("TKN", { fetchFn: mockFetch });
110-
```
111-
112-
## Testing
113-
114-
```bash
115-
pnpm test
116-
```
117-
118-
## Building / Publishing
119-
120-
```bash
121-
pnpm run build # esm + cjs
122-
pnpm run release # bump version & publish (uses bumpp)
123-
```
124-
125-
Artifacts:
126-
127-
```
128-
dist/index.js (ESM)
129-
dist/index.cjs (CJS)
130-
dist/index.d.ts (types)
120+
const client = createReplaneClient({
121+
apiKey: "TKN",
122+
baseUrl: "https://api",
123+
fetchFn: mockFetch,
124+
});
131125
```
132126

133-
## Roadmap (short)
127+
## Roadmap
134128

135-
- Optional batch fetch
136-
- ETag / conditional requests
137-
- Minimal caching utilities (opt‑in)
129+
- Config caching
130+
- Config invalidation
138131

139132
## License
140133

src/index.ts

Lines changed: 112 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,65 @@ export interface ReplaneClientOptions {
33
baseUrl: string;
44
/** Custom fetch implementation (useful for tests / polyfills). */
55
fetchFn?: typeof fetch;
6-
/** Optional timeout in ms for the request. */
6+
/**
7+
* Optional timeout in ms for the request.
8+
* @default 1000
9+
*/
710
timeoutMs?: number;
8-
/** API key for authorization. */
11+
/** Project API key for authorization. */
912
apiKey: string;
13+
/** Optional logger (defaults to console). */
14+
logger?: ReplaneLogger;
1015
}
1116

12-
export class ReplaneError extends Error {
13-
status: number;
14-
body: unknown;
15-
constructor(message: string, status: number, body: unknown) {
16-
super(message);
17-
this.name = "ReplaneError";
18-
this.status = status;
19-
this.body = body;
20-
}
17+
interface ReplaneFinalOptions {
18+
baseUrl: string;
19+
fetchFn: typeof fetch;
20+
timeoutMs: number;
21+
apiKey: string;
22+
logger: ReplaneLogger;
2123
}
2224

25+
export interface ReplaneLogger {
26+
debug(...args: any[]): void;
27+
info(...args: any[]): void;
28+
warn(...args: any[]): void;
29+
error(...args: any[]): void;
30+
}
31+
32+
const defaultLogger: ReplaneLogger = console;
33+
2334
/** Internal helper adding timeout support around fetch. */
2435
// Use a looser 'any' for input to avoid depending on DOM lib types.
2536
async function fetchWithTimeout(
2637
input: any,
2738
init: RequestInit,
28-
timeoutMs?: number,
29-
fetchFn?: typeof fetch
39+
timeoutMs: number,
40+
fetchFn: typeof fetch
3041
) {
31-
const fn = fetchFn ?? (globalThis.fetch as typeof fetch | undefined);
32-
if (!fn) {
42+
if (!fetchFn) {
3343
throw new Error("Global fetch is not available. Provide options.fetchFn.");
3444
}
35-
if (!timeoutMs) return fn(input, init);
45+
if (!timeoutMs) return fetchFn(input, init);
3646
const controller = new AbortController();
3747
const t = setTimeout(() => controller.abort(), timeoutMs);
3848
try {
39-
return await fn(input, { ...init, signal: controller.signal });
49+
return await fetchFn(input, { ...init, signal: controller.signal });
4050
} finally {
4151
clearTimeout(t);
4252
}
4353
}
4454

45-
export interface GetConfigOptions extends Partial<ReplaneClientOptions> {}
46-
47-
/** Shape of a successful config value response.
48-
* The API might just return the raw value. We accept unknown to stay flexible.
49-
*/
50-
export type ConfigValue<T = unknown> = T;
55+
export interface GetConfigRequest<T> extends Partial<ReplaneClientOptions> {
56+
/** Config name to fetch. */
57+
name: string;
58+
/** Fallback value if config is not found. */
59+
fallback: T;
60+
}
5161

5262
export interface ReplaneClient {
5363
/** Fetch a config value by name. */
54-
getConfig<T = unknown>(
55-
name: string,
56-
options?: GetConfigOptions
57-
): Promise<ConfigValue<T>>;
64+
getConfig<T = unknown>(req: GetConfigRequest<T>): Promise<T | undefined>;
5865
}
5966

6067
/**
@@ -64,52 +71,90 @@ export interface ReplaneClient {
6471
* const value = await client.getConfig('my-config')
6572
*/
6673
export function createReplaneClient(
67-
options: ReplaneClientOptions
74+
sdkOptions: ReplaneClientOptions
6875
): ReplaneClient {
69-
if (!options.apiKey) throw new Error("API key is required");
76+
if (!sdkOptions.apiKey) throw new Error("API key is required");
7077

7178
return {
72-
async getConfig<T = unknown>(
73-
name: string,
74-
perCallOptions: GetConfigOptions = {}
75-
): Promise<ConfigValue<T>> {
76-
if (!name) throw new Error("config name is required");
77-
const finalOptions = { ...options, ...perCallOptions };
78-
const finalBase = finalOptions.baseUrl.replace(/\/$/, "");
79-
const url = `${finalBase}/api/v1/configs/${encodeURIComponent(
80-
name
81-
)}/value`;
82-
const res = await fetchWithTimeout(
83-
url,
84-
{
85-
method: "GET",
86-
headers: {
87-
Authorization: `Bearer ${finalOptions.apiKey}`,
88-
Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
89-
},
90-
},
91-
perCallOptions.timeoutMs ?? finalOptions.timeoutMs,
92-
perCallOptions.fetchFn ?? finalOptions.fetchFn
93-
);
94-
95-
let body: unknown = null;
96-
const contentType = res.headers.get("content-type") || "";
79+
async getConfig<T = unknown>(req: GetConfigRequest<T>): Promise<T> {
80+
if (!req.name) throw new Error("config name is required");
81+
const finalOptions = combineOptions(sdkOptions, req);
9782
try {
98-
if (contentType.includes("application/json")) body = await res.json();
99-
else body = await res.text();
100-
} catch (e) {
101-
// ignore body parse errors; body stays null
102-
}
103-
104-
if (!res.ok) {
105-
throw new ReplaneError(
106-
`Failed to fetch config "${name}" (status ${res.status})`,
107-
res.status,
108-
body
109-
);
83+
return await _getConfig<T>({
84+
configName: req.name,
85+
fallback: req.fallback,
86+
options: finalOptions,
87+
});
88+
} catch (err: unknown) {
89+
finalOptions.logger.error("ReplaneClient.getConfig error", err);
90+
return req.fallback;
11091
}
92+
},
93+
};
94+
}
11195

112-
return body as T;
96+
async function _getConfig<T>(params: {
97+
configName: string;
98+
fallback: T;
99+
options: ReplaneFinalOptions;
100+
}): Promise<T> {
101+
const url = `${params.options.baseUrl}/api/v1/configs/${encodeURIComponent(
102+
params.configName
103+
)}/value`;
104+
const res = await fetchWithTimeout(
105+
url,
106+
{
107+
method: "GET",
108+
headers: {
109+
Authorization: `Bearer ${params.options.apiKey}`,
110+
Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
111+
},
113112
},
113+
params.options.timeoutMs,
114+
params.options.fetchFn
115+
);
116+
117+
let body: unknown = null;
118+
const contentType = res.headers.get("content-type") || "";
119+
try {
120+
if (contentType.includes("application/json")) {
121+
body = await res.json();
122+
} else {
123+
body = await res.text();
124+
}
125+
} catch (e) {
126+
if (res.ok) {
127+
params.options.logger.error("ReplaneClient.getConfig invalid response", {
128+
name: params.configName,
129+
status: res.status,
130+
contentType,
131+
});
132+
return params.fallback;
133+
}
134+
}
135+
136+
if (!res.ok) {
137+
params.options.logger.error("ReplaneClient.getConfig error", {
138+
name: params.configName,
139+
status: res.status,
140+
body,
141+
});
142+
143+
return params.fallback;
144+
}
145+
146+
return body as T;
147+
}
148+
149+
function combineOptions(
150+
defaults: ReplaneClientOptions,
151+
overrides: Partial<ReplaneClientOptions>
152+
): ReplaneFinalOptions {
153+
return {
154+
apiKey: overrides.apiKey ?? defaults.apiKey,
155+
baseUrl: (overrides.baseUrl ?? defaults.baseUrl).replace(/\/+$/, ""),
156+
fetchFn: overrides.fetchFn ?? defaults.fetchFn ?? globalThis.fetch,
157+
timeoutMs: overrides.timeoutMs ?? defaults.timeoutMs ?? 5000,
158+
logger: overrides.logger ?? defaults.logger ?? defaultLogger,
114159
};
115160
}

0 commit comments

Comments
 (0)