Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ Versions prior to v1.3.0 were maintained in a private repository (history unavil
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.8.0] - 2026-06-22

### Added

- **Idempotency support** — mutation endpoints (POST, PUT, PATCH, DELETE) now accept an `idempotencyKey` via `RequestOptions`. The API deduplicates requests sharing the same key within a 24-hour window; subsequent retries with the same key replay the stored response without re-executing side effects.
- `RequestOptions.idempotencyKey?: string` — pass `crypto.randomUUID()` once per logical operation and reuse on every retry of that operation.
- `IDEMPOTENCY_KEY_HEADER` constant (`'Idempotency-Key'`) exported from the root package — use when inspecting outgoing request headers.
- `IDEMPOTENT_REPLAYED_HEADER` constant (`'Idempotent-Replayed'`) exported from the root package — check this response header to detect replayed responses.
- A 409 conflict (same key already in flight) surfaces as a `ContioAPIError` with `statusCode: 409`; the `retryAfter` field indicates when to retry.
- Idempotency keys are ignored on GET/HEAD requests (naturally idempotent) and multipart/file-upload endpoints.

## [1.7.1] - 2026-05-21

### Changed
Expand Down Expand Up @@ -307,6 +318,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Error handling**: `ContioAPIError` with structured error information
- **Retry logic**: Automatic retry with exponential backoff for transient failures

[1.8.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.7.1...v1.8.0
[1.7.1]: https://github.com/Contio-AI/partner-sdk/compare/v1.7.0...v1.7.1
[1.7.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.6.0...v1.7.0
[1.6.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.5.0...v1.6.0
[1.5.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.4.7...v1.5.0
[1.4.7]: https://github.com/Contio-AI/partner-sdk/compare/v1.4.6...v1.4.7
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contio/partner-sdk",
"version": "1.7.1",
"version": "1.8.0",
"description": "Official SDK for Contio MeetingOS Partner API",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
40 changes: 38 additions & 2 deletions src/client/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ErrorResponse } from '../models/auth';
* SDK version for User-Agent header.
* This is updated during the release process.
*/
export const SDK_VERSION = '1.4.5';
export const SDK_VERSION = '1.8.0';

export interface ClientConfig {
baseURL?: string;
Expand All @@ -34,13 +34,42 @@ export interface RequestOptions {
* (e.g., 'America/New_York') or a timezone abbreviation (e.g., 'EST', 'PST').
*/
timezone?: string;
/**
* Idempotency key for safe retries on mutation requests (POST, PUT, PATCH, DELETE).
* The API deduplicates requests sharing the same key within a 24-hour window —
* the first request executes normally and its response is stored; subsequent retries
* with the same key replay the stored response without re-executing.
*
* - Ignored on GET/HEAD requests (they are naturally idempotent).
* - Ignored on multipart/file-upload endpoints.
* - Must be ≤255 printable ASCII characters.
* - A conflict (another request with the same key still in flight) returns a 409
* error whose {@link ContioAPIError.retryAfter} field indicates when to retry.
*
* Use `crypto.randomUUID()` to generate a unique key per logical operation and
* reuse that same key on every retry of that operation.
*/
idempotencyKey?: string;
}

/**
* Header name for client timezone
*/
export const TIMEZONE_HEADER = 'X-Client-Timezone';

/**
* Header name for idempotency keys on mutation requests.
* @see {@link RequestOptions.idempotencyKey}
*/
export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';

/**
* Response header set to `"true"` by the API when the response body is a
* replay of a previously captured response (i.e. the idempotency key was
* already used and the original response is being returned verbatim).
*/
export const IDEMPOTENT_REPLAYED_HEADER = 'Idempotent-Replayed';

export class ContioAPIError extends Error {
public readonly code: string;
public readonly statusCode?: number;
Expand Down Expand Up @@ -123,7 +152,7 @@ export abstract class BaseClient {
protected abstract addAuthHeaders(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;

/**
* Build axios config with optional per-request timezone override
* Build axios config with optional per-request overrides (timezone, idempotency key).
*/
protected buildRequestConfig(options?: RequestOptions, baseConfig?: AxiosRequestConfig): AxiosRequestConfig {
const config: AxiosRequestConfig = { ...baseConfig };
Expand All @@ -135,6 +164,13 @@ export abstract class BaseClient {
};
}

if (options?.idempotencyKey) {
config.headers = {
...config.headers,
[IDEMPOTENCY_KEY_HEADER]: options.idempotencyKey,
};
}

return config;
}

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export { ApiKeyClient } from './auth/apiKey';
// Export API clients
export { PartnerUserClient } from './client/user';
export { PartnerAdminClient } from './client/admin';
export { BaseClient, ContioAPIError, TIMEZONE_HEADER } from './client/base';
export { BaseClient, ContioAPIError, TIMEZONE_HEADER, IDEMPOTENCY_KEY_HEADER, IDEMPOTENT_REPLAYED_HEADER } from './client/base';
export type { ClientConfig, RequestOptions } from './client/base';

// Export all models
Expand Down
75 changes: 74 additions & 1 deletion tests/base-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import MockAdapter from 'axios-mock-adapter';
import { PartnerUserClient } from '../src/client/user';
import { OAuthClient } from '../src/auth/oauth';
import { ContioAPIError } from '../src/client/base';
import { ContioAPIError, IDEMPOTENCY_KEY_HEADER, IDEMPOTENT_REPLAYED_HEADER } from '../src/client/base';

describe('BaseClient', () => {
let oauthClient: OAuthClient;
Expand Down Expand Up @@ -414,6 +414,79 @@ describe('BaseClient', () => {
});
});

describe('Idempotency', () => {
it('should send Idempotency-Key header on POST requests', async () => {
mockAxios.onPost('/meetings').reply((config) => {
expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBe('test-idem-key');
return [201, { id: 'm1', title: 'Test' }];
});

await userClient.createMeeting(
{ title: 'Test', start_time: '2026-06-22T10:00:00Z', end_time: '2026-06-22T11:00:00Z', workspace_id: 'ws1' },
{ idempotencyKey: 'test-idem-key' },
);
});

it('should send Idempotency-Key header on PATCH requests', async () => {
mockAxios.onPatch('/meetings/m1').reply((config) => {
expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBe('patch-key');
return [200, { id: 'm1', title: 'Updated' }];
});

await userClient.updateMeeting('m1', { title: 'Updated' }, { idempotencyKey: 'patch-key' });
});

it('should send Idempotency-Key header on DELETE requests', async () => {
mockAxios.onDelete('/meetings/m1/participants/p1').reply((config) => {
expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBe('delete-key');
return [204, null];
});

await userClient.removeMeetingParticipant('m1', 'p1', { idempotencyKey: 'delete-key' });
});

it('should not send Idempotency-Key header when idempotencyKey is not set', async () => {
mockAxios.onPost('/meetings').reply((config) => {
expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBeUndefined();
return [201, { id: 'm1', title: 'Test' }];
});

await userClient.createMeeting(
{ title: 'Test', start_time: '2026-06-22T10:00:00Z', end_time: '2026-06-22T11:00:00Z', workspace_id: 'ws1' },
);
});

it('should surface retryAfter on 409 in-flight conflict', async () => {
const noRetryClient = new PartnerUserClient(oauthClient, { retries: 0 });
const noRetryMock = new MockAdapter((noRetryClient as any).axiosInstance);

noRetryMock.onPost('/meetings').reply(
409,
{ error: 'idempotency_conflict', code: 'idempotency_conflict', message: 'Request in progress' },
{ 'retry-after': '5' },
);

try {
await noRetryClient.createMeeting(
{ title: 'Test', start_time: '2026-06-22T10:00:00Z', end_time: '2026-06-22T11:00:00Z', workspace_id: 'ws1' },
{ idempotencyKey: 'conflict-key' },
);
fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ContioAPIError);
const apiError = error as ContioAPIError;
expect(apiError.statusCode).toBe(409);
expect(apiError.retryAfter).toBe('5');
}
noRetryMock.reset();
});

it('should export IDEMPOTENCY_KEY_HEADER and IDEMPOTENT_REPLAYED_HEADER constants', () => {
expect(IDEMPOTENCY_KEY_HEADER).toBe('Idempotency-Key');
expect(IDEMPOTENT_REPLAYED_HEADER).toBe('Idempotent-Replayed');
});
});

describe('Request interceptor error path', () => {
it('should reject with the error when request interceptor fails', async () => {
const noRetryClient = new PartnerUserClient(oauthClient, { retries: 0 });
Expand Down
Loading