From 37932e29d603b15bc54637bf39a7674bbb1d6349 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Wed, 11 Nov 2026 17:34:38 +0000 Subject: [PATCH 1/2] feat(sdk): send an Idempotency-Key with a project create --- sdk/openapi/cloud.openapi.yaml | 25 +-------------- sdk/openapi/projects.yaml | 56 ++++++++++++++++++++++++++++++++++ sdk/src/api.ts | 22 +++++++++++-- sdk/src/generated/cloud-api.ts | 43 +++++++++++++++++++++++++- sdk/test/api.test.ts | 24 +++++++++++++++ 5 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 sdk/openapi/projects.yaml diff --git a/sdk/openapi/cloud.openapi.yaml b/sdk/openapi/cloud.openapi.yaml index d8dcc83..c9f068b 100644 --- a/sdk/openapi/cloud.openapi.yaml +++ b/sdk/openapi/cloud.openapi.yaml @@ -313,30 +313,7 @@ paths: application/json: schema: { $ref: '#/components/schemas/DeviceToken' } default: { $ref: '#/components/responses/Error' } - /api/cloud/projects/: - get: - operationId: listProjects - security: [{ bearerAuth: [] }] - responses: - '200': - content: - application/json: - schema: { $ref: '#/components/schemas/ProjectList' } - default: { $ref: '#/components/responses/Error' } - post: - operationId: createProject - security: [{ bearerAuth: [] }] - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/NewProject' } - responses: - '200': - content: - application/json: - schema: { $ref: '#/components/schemas/CreatedProject' } - default: { $ref: '#/components/responses/Error' } + /api/cloud/projects/: { $ref: 'projects.yaml#/paths/projects' } /api/cloud/projects/{projectId}: get: operationId: readProject diff --git a/sdk/openapi/projects.yaml b/sdk/openapi/projects.yaml new file mode 100644 index 0000000..94ae093 --- /dev/null +++ b/sdk/openapi/projects.yaml @@ -0,0 +1,56 @@ +paths: + projects: + get: + operationId: listProjects + security: [{ bearerAuth: [] }] + responses: + '200': + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/ProjectList' } + default: { $ref: './cloud.openapi.yaml#/components/responses/Error' } + post: + operationId: createProject + description: | + Creates a project with its production environment and, from a repository, its first service. + + A client that may lose the answer sends an `Idempotency-Key` and retries with the same key and + the same body. The retry answers 200 with the project that key created, as a fresh create + would: its `projectId`, current `slug` and `region`, and the original `environmentId` and + `serviceId`. No second project is created. Keys are scoped to the workspace: the same key in + another workspace is a different key. A create with another key, or none, on a taken slug + still answers 409 `cloud.errors.projects.slug_taken`. + security: [{ bearerAuth: [] }] + parameters: + - in: header + name: Idempotency-Key + required: false + description: Identifies one create across retries. 1 to 255 visible ASCII characters. + schema: { type: string, minLength: 1, maxLength: 255, pattern: '^[\x21-\x7E]+$' } + requestBody: + required: true + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/NewProject' } + responses: + '200': + description: The project created, or the one this `Idempotency-Key` already created. + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/CreatedProject' } + '400': + description: '`cloud.errors.projects.invalid_idempotency_key`: the `Idempotency-Key` is empty, longer than 255 characters, or holds a character outside 0x21-0x7E.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '409': + description: '`cloud.errors.projects.slug_taken`: another project holds the slug.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '422': + description: '`cloud.errors.projects.idempotency_key_reused`: the `Idempotency-Key` already created a project from a different request.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + default: { $ref: './cloud.openapi.yaml#/components/responses/Error' } diff --git a/sdk/src/api.ts b/sdk/src/api.ts index 85280de..806f21f 100644 --- a/sdk/src/api.ts +++ b/sdk/src/api.ts @@ -43,6 +43,15 @@ export interface CloudApiOptions extends EndpointOptions { token: string; } +/** How a project create is sent. */ +export interface CreateProjectOptions { + /** + * Identifies this create across retries, 1 to 255 visible ASCII characters. + * Scoped to the workspace. + */ + idempotencyKey?: string; +} + /** How a log read is shaped: one bounded page, or a tail from a cursor. */ export interface LogRequest { follow: boolean; @@ -125,8 +134,17 @@ export class CloudApi { return dataOf(result); } - async createProject(body: NewProject) { - const result = await this.client.POST("/api/cloud/projects/", { body }); + /** + * Creates a project. A retry with the same `idempotencyKey` and the same + * body answers the project that key created instead of a second one; the + * same key with another body is refused with + * `cloud.errors.projects.idempotency_key_reused`. + */ + async createProject(body: NewProject, options: CreateProjectOptions = {}) { + const result = await this.client.POST("/api/cloud/projects/", { + body, + params: { header: { "Idempotency-Key": options.idempotencyKey } }, + }); return dataOf(result); } diff --git a/sdk/src/generated/cloud-api.ts b/sdk/src/generated/cloud-api.ts index fe4ea51..2a24c45 100644 --- a/sdk/src/generated/cloud-api.ts +++ b/sdk/src/generated/cloud-api.ts @@ -77,6 +77,16 @@ export interface paths { }; get: operations["listProjects"]; put?: never; + /** + * @description Creates a project with its production environment and, from a repository, its first service. + * + * A client that may lose the answer sends an `Idempotency-Key` and retries with the same key and + * the same body. The retry answers 200 with the project that key created, as a fresh create + * would: its `projectId`, current `slug` and `region`, and the original `environmentId` and + * `serviceId`. No second project is created. Keys are scoped to the workspace: the same key in + * another workspace is a different key. A create with another key, or none, on a taken slug + * still answers 409 `cloud.errors.projects.slug_taken`. + */ post: operations["createProject"]; delete?: never; options?: never; @@ -1103,7 +1113,10 @@ export interface operations { createProject: { parameters: { query?: never; - header?: never; + header?: { + /** @description Identifies one create across retries. 1 to 255 visible ASCII characters. */ + "Idempotency-Key"?: string; + }; path?: never; cookie?: never; }; @@ -1113,6 +1126,7 @@ export interface operations { }; }; responses: { + /** @description The project created, or the one this `Idempotency-Key` already created. */ 200: { headers: { [name: string]: unknown; @@ -1121,6 +1135,33 @@ export interface operations { "application/json": components["schemas"]["CreatedProject"]; }; }; + /** @description `cloud.errors.projects.invalid_idempotency_key`: the `Idempotency-Key` is empty, longer than 255 characters, or holds a character outside 0x21-0x7E. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `cloud.errors.projects.slug_taken`: another project holds the slug. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `cloud.errors.projects.idempotency_key_reused`: the `Idempotency-Key` already created a project from a different request. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; default: components["responses"]["Error"]; }; }; diff --git a/sdk/test/api.test.ts b/sdk/test/api.test.ts index 57bfc79..7acb1d1 100644 --- a/sdk/test/api.test.ts +++ b/sdk/test/api.test.ts @@ -13,6 +13,7 @@ interface Sent { url: string; method: string; authorization: string | null; + idempotencyKey: string | null; body: string; } @@ -25,6 +26,7 @@ function recorder(answer: () => Response) { url: request.url, method: request.method, authorization: request.headers.get("authorization"), + idempotencyKey: request.headers.get("idempotency-key"), body: await request.text(), }); return answer(); @@ -115,6 +117,28 @@ describe("projects", () => { branch: "main", }); }); + + test("a create sends its idempotency key as the Idempotency-Key header, and no header without one", async () => { + const created = { + projectId: "p-1", + slug: "shop", + region: "eu", + environmentId: "e-1", + serviceId: null, + }; + const { sent, send } = recorder(() => json(200, JSON.stringify(created))); + const api = new CloudApi({ token: "t", fetch: send }); + await api.createProject( + { slug: "shop" }, + { idempotencyKey: "setup-2f6c1e0a" }, + ); + await api.createProject({ slug: "shop" }); + assert.deepEqual( + sent.map((request) => request.idempotencyKey), + ["setup-2f6c1e0a", null], + ); + assert.deepEqual(JSON.parse(sent[0]?.body ?? ""), { slug: "shop" }); + }); }); describe("refusals", () => { From 5b4549e8ee637a5b3249ea5e8db55250b0c39292 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Wed, 11 Nov 2026 18:00:06 +0000 Subject: [PATCH 2/2] docs(sdk): align the project create spec with the cloud's idempotency rules --- sdk/openapi/projects.yaml | 23 ++++++++++++++++------- sdk/src/api.ts | 12 +++++++----- sdk/src/generated/cloud-api.ts | 23 ++++++++++++++++------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/sdk/openapi/projects.yaml b/sdk/openapi/projects.yaml index 94ae093..703dd18 100644 --- a/sdk/openapi/projects.yaml +++ b/sdk/openapi/projects.yaml @@ -15,11 +15,20 @@ paths: Creates a project with its production environment and, from a repository, its first service. A client that may lose the answer sends an `Idempotency-Key` and retries with the same key and - the same body. The retry answers 200 with the project that key created, as a fresh create - would: its `projectId`, current `slug` and `region`, and the original `environmentId` and - `serviceId`. No second project is created. Keys are scoped to the workspace: the same key in - another workspace is a different key. A create with another key, or none, on a taken slug - still answers 409 `cloud.errors.projects.slug_taken`. + the same request: the same `slug`, `region`, `repository` and `branch`. Once the first create + finished, the retry answers 200 with the project that key created, as a fresh create would: + its `projectId`, current `slug` and `region`, and the original `environmentId` and + `serviceId`. No second project is created, and the replay needs no GitHub call and no room + under the plan's project ceiling. + + A key belongs to its workspace and to the member who sent it. Another member sending the + same key and body is not given the project; on its taken slug they get 409 + `cloud.errors.projects.slug_taken`, as does a create with another key, or none. + + While another create holds the slug, a keyed request answers 409 + `cloud.errors.projects.creation_in_progress`: it may be its own first attempt still running, + so the client retries with the same key. The same key from the same member with a different + request answers 422 `cloud.errors.projects.idempotency_key_reused`. security: [{ bearerAuth: [] }] parameters: - in: header @@ -44,12 +53,12 @@ paths: application/json: schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } '409': - description: '`cloud.errors.projects.slug_taken`: another project holds the slug.' + description: '`cloud.errors.projects.slug_taken`: another project holds the slug. `cloud.errors.projects.creation_in_progress`: a keyed request met another create holding the slug; retry with the same key.' content: application/json: schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } '422': - description: '`cloud.errors.projects.idempotency_key_reused`: the `Idempotency-Key` already created a project from a different request.' + description: '`cloud.errors.projects.idempotency_key_reused`: the same member''s `Idempotency-Key` already created a project from a request with a different `slug`, `region`, `repository` or `branch`.' content: application/json: schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } diff --git a/sdk/src/api.ts b/sdk/src/api.ts index 806f21f..e5dd73d 100644 --- a/sdk/src/api.ts +++ b/sdk/src/api.ts @@ -47,7 +47,7 @@ export interface CloudApiOptions extends EndpointOptions { export interface CreateProjectOptions { /** * Identifies this create across retries, 1 to 255 visible ASCII characters. - * Scoped to the workspace. + * Scoped to the workspace and to the member who sends it. */ idempotencyKey?: string; } @@ -135,10 +135,12 @@ export class CloudApi { } /** - * Creates a project. A retry with the same `idempotencyKey` and the same - * body answers the project that key created instead of a second one; the - * same key with another body is refused with - * `cloud.errors.projects.idempotency_key_reused`. + * Creates a project. A retry by the same member with the same + * `idempotencyKey` and the same body answers the project that key created + * instead of a second one; the same key with another body is refused with + * `cloud.errors.projects.idempotency_key_reused`, and + * `cloud.errors.projects.creation_in_progress` asks for a retry with the + * same key. */ async createProject(body: NewProject, options: CreateProjectOptions = {}) { const result = await this.client.POST("/api/cloud/projects/", { diff --git a/sdk/src/generated/cloud-api.ts b/sdk/src/generated/cloud-api.ts index 2a24c45..344dcb2 100644 --- a/sdk/src/generated/cloud-api.ts +++ b/sdk/src/generated/cloud-api.ts @@ -81,11 +81,20 @@ export interface paths { * @description Creates a project with its production environment and, from a repository, its first service. * * A client that may lose the answer sends an `Idempotency-Key` and retries with the same key and - * the same body. The retry answers 200 with the project that key created, as a fresh create - * would: its `projectId`, current `slug` and `region`, and the original `environmentId` and - * `serviceId`. No second project is created. Keys are scoped to the workspace: the same key in - * another workspace is a different key. A create with another key, or none, on a taken slug - * still answers 409 `cloud.errors.projects.slug_taken`. + * the same request: the same `slug`, `region`, `repository` and `branch`. Once the first create + * finished, the retry answers 200 with the project that key created, as a fresh create would: + * its `projectId`, current `slug` and `region`, and the original `environmentId` and + * `serviceId`. No second project is created, and the replay needs no GitHub call and no room + * under the plan's project ceiling. + * + * A key belongs to its workspace and to the member who sent it. Another member sending the + * same key and body is not given the project; on its taken slug they get 409 + * `cloud.errors.projects.slug_taken`, as does a create with another key, or none. + * + * While another create holds the slug, a keyed request answers 409 + * `cloud.errors.projects.creation_in_progress`: it may be its own first attempt still running, + * so the client retries with the same key. The same key from the same member with a different + * request answers 422 `cloud.errors.projects.idempotency_key_reused`. */ post: operations["createProject"]; delete?: never; @@ -1144,7 +1153,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `cloud.errors.projects.slug_taken`: another project holds the slug. */ + /** @description `cloud.errors.projects.slug_taken`: another project holds the slug. `cloud.errors.projects.creation_in_progress`: a keyed request met another create holding the slug; retry with the same key. */ 409: { headers: { [name: string]: unknown; @@ -1153,7 +1162,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `cloud.errors.projects.idempotency_key_reused`: the `Idempotency-Key` already created a project from a different request. */ + /** @description `cloud.errors.projects.idempotency_key_reused`: the same member's `Idempotency-Key` already created a project from a request with a different `slug`, `region`, `repository` or `branch`. */ 422: { headers: { [name: string]: unknown;