-
-
Notifications
You must be signed in to change notification settings - Fork 914
feat(webapp): Add support for resetting idempotency keys #2777
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { json } from "@remix-run/server-runtime"; | ||
| import { z } from "zod"; | ||
| import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | ||
| import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server"; | ||
|
|
||
| const ParamsSchema = z.object({ | ||
| key: z.string(), | ||
| }); | ||
|
|
||
| const BodySchema = z.object({ | ||
| taskIdentifier: z.string().min(1, "Task identifier is required"), | ||
| }); | ||
|
|
||
| export const { action } = createActionApiRoute( | ||
| { | ||
| params: ParamsSchema, | ||
| body: BodySchema, | ||
| allowJWT: true, | ||
| corsStrategy: "all", | ||
| authorization: { | ||
| action: "write", | ||
| resource: () => ({}), | ||
| superScopes: ["write:runs", "admin"], | ||
| }, | ||
| }, | ||
| async ({ params, body, authentication }) => { | ||
| const service = new ResetIdempotencyKeyService(); | ||
|
|
||
| try { | ||
| const result = await service.call(params.key, body.taskIdentifier, authentication.environment); | ||
| return json(result, { status: 200 }); | ||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| return json({ error: error.message }, { status: 404 }); | ||
| } | ||
| return json({ error: "Internal Server Error" }, { status: 500 }); | ||
| } | ||
| } | ||
| ); | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,133 @@ | ||||||||||
| import { parse } from "@conform-to/zod"; | ||||||||||
| import { type ActionFunction, json } from "@remix-run/node"; | ||||||||||
| import { z } from "zod"; | ||||||||||
| import { prisma } from "~/db.server"; | ||||||||||
| import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server"; | ||||||||||
| import { logger } from "~/services/logger.server"; | ||||||||||
| import { requireUserId } from "~/services/session.server"; | ||||||||||
| import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server"; | ||||||||||
| import { v3RunParamsSchema } from "~/utils/pathBuilder"; | ||||||||||
| import { authenticateApiRequest } from "~/services/apiAuth.server"; | ||||||||||
| import { environment } from "effect/Differ"; | ||||||||||
|
Comment on lines
+10
to
+11
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Remove unused imports.
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
-import { authenticateApiRequest } from "~/services/apiAuth.server";
-import { environment } from "effect/Differ";📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| export const resetIdempotencyKeySchema = z.object({ | ||||||||||
| taskIdentifier: z.string().min(1, "Task identifier is required"), | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| export const action: ActionFunction = async ({ request, params }) => { | ||||||||||
| const userId = await requireUserId(request); | ||||||||||
| const { projectParam, organizationSlug, envParam, runParam } = | ||||||||||
| v3RunParamsSchema.parse(params); | ||||||||||
|
|
||||||||||
| const formData = await request.formData(); | ||||||||||
| const submission = parse(formData, { schema: resetIdempotencyKeySchema }); | ||||||||||
|
|
||||||||||
| if (!submission.value) { | ||||||||||
| return json(submission); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| try { | ||||||||||
| const { taskIdentifier } = submission.value; | ||||||||||
|
|
||||||||||
| const taskRun = await prisma.taskRun.findFirst({ | ||||||||||
| where: { | ||||||||||
| friendlyId: runParam, | ||||||||||
| project: { | ||||||||||
| slug: projectParam, | ||||||||||
| organization: { | ||||||||||
| slug: organizationSlug, | ||||||||||
| members: { | ||||||||||
| some: { | ||||||||||
| userId, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| runtimeEnvironment: { | ||||||||||
| slug: envParam, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| select: { | ||||||||||
| id: true, | ||||||||||
| idempotencyKey: true, | ||||||||||
| taskIdentifier: true, | ||||||||||
| runtimeEnvironmentId: true, | ||||||||||
| }, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| if (!taskRun) { | ||||||||||
| submission.error = { runParam: ["Run not found"] }; | ||||||||||
| return json(submission); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (!taskRun.idempotencyKey) { | ||||||||||
| return jsonWithErrorMessage( | ||||||||||
| submission, | ||||||||||
| request, | ||||||||||
| "This run does not have an idempotency key" | ||||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (taskRun.taskIdentifier !== taskIdentifier) { | ||||||||||
| submission.error = { taskIdentifier: ["Task identifier does not match this run"] }; | ||||||||||
| return json(submission); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const environment = await prisma.runtimeEnvironment.findUnique({ | ||||||||||
| where: { | ||||||||||
| id: taskRun.runtimeEnvironmentId, | ||||||||||
| }, | ||||||||||
| include: { | ||||||||||
| project: { | ||||||||||
| include: { | ||||||||||
| organization: true, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| if (!environment) { | ||||||||||
| return jsonWithErrorMessage( | ||||||||||
| submission, | ||||||||||
| request, | ||||||||||
| "Environment not found" | ||||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const service = new ResetIdempotencyKeyService(); | ||||||||||
|
|
||||||||||
| await service.call(taskRun.idempotencyKey, taskIdentifier, { | ||||||||||
| ...environment, | ||||||||||
| organizationId: environment.project.organizationId, | ||||||||||
| organization: environment.project.organization, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| return jsonWithSuccessMessage( | ||||||||||
| { success: true }, | ||||||||||
| request, | ||||||||||
| "Idempotency key reset successfully" | ||||||||||
| ); | ||||||||||
| } catch (error) { | ||||||||||
| if (error instanceof Error) { | ||||||||||
| logger.error("Failed to reset idempotency key", { | ||||||||||
| error: { | ||||||||||
| name: error.name, | ||||||||||
| message: error.message, | ||||||||||
| stack: error.stack, | ||||||||||
| }, | ||||||||||
| }); | ||||||||||
| return jsonWithErrorMessage( | ||||||||||
| submission, | ||||||||||
| request, | ||||||||||
| `Failed to reset idempotency key: ${error.message}` | ||||||||||
| ); | ||||||||||
| } else { | ||||||||||
| logger.error("Failed to reset idempotency key", { error }); | ||||||||||
| return jsonWithErrorMessage( | ||||||||||
| submission, | ||||||||||
| request, | ||||||||||
| `Failed to reset idempotency key: ${JSON.stringify(error)}` | ||||||||||
| ); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| }; | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; | ||
| import { BaseService, ServiceValidationError } from "./baseService.server"; | ||
|
|
||
| export class ResetIdempotencyKeyService extends BaseService { | ||
| public async call( | ||
| idempotencyKey: string, | ||
| taskIdentifier: string, | ||
| authenticatedEnv: AuthenticatedEnvironment | ||
| ): Promise<{ id: string }> { | ||
| // Find all runs with this idempotency key and task identifier in the authenticated environment | ||
| const runs = await this._prisma.taskRun.findMany({ | ||
| where: { | ||
| idempotencyKey, | ||
| taskIdentifier, | ||
| runtimeEnvironmentId: authenticatedEnv.id, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (runs.length === 0) { | ||
| throw new ServiceValidationError( | ||
| `No runs found with idempotency key: ${idempotencyKey} and task: ${taskIdentifier}`, | ||
| 404 | ||
| ); | ||
| } | ||
|
|
||
| // Update all runs to clear the idempotency key | ||
| await this._prisma.taskRun.updateMany({ | ||
| where: { | ||
| idempotencyKey, | ||
| taskIdentifier, | ||
| runtimeEnvironmentId: authenticatedEnv.id, | ||
| }, | ||
| data: { | ||
| idempotencyKey: null, | ||
| idempotencyKeyExpiresAt: null, | ||
| }, | ||
| }); | ||
|
|
||
| return { id: idempotencyKey }; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,6 +153,29 @@ function hash(payload: any): string { | |
| } | ||
| ``` | ||
|
|
||
| ## Resetting idempotency keys | ||
|
|
||
| You can reset an idempotency key to clear it from all associated runs. This is useful if you need to allow a task to be triggered again with the same idempotency key. | ||
|
|
||
| When you reset an idempotency key, it will be cleared for all runs that match both the task identifier and the idempotency key in the current environment. This allows you to trigger the task again with the same key. | ||
|
|
||
| ```ts | ||
| import { idempotencyKeys } from "@trigger.dev/sdk"; | ||
|
|
||
| // Reset an idempotency key for a specific task | ||
| await idempotencyKeys.reset("my-task", "my-idempotency-key"); | ||
| ``` | ||
|
|
||
| The `reset` function requires both parameters: | ||
| - `taskIdentifier`: The identifier of the task (e.g., `"my-task"`) | ||
| - `idempotencyKey`: The idempotency key to reset | ||
|
|
||
| After resetting, any subsequent triggers with the same idempotency key will create new task runs instead of returning the existing ones. | ||
|
|
||
| <Note> | ||
| Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination. | ||
| </Note> | ||
|
Comment on lines
+156
to
+177
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clarify which The new section is clear on scope (task identifier + environment), but the example: await idempotencyKeys.reset("my-task", "my-idempotency-key");can be read as passing the original key material rather than the stored key value (typically the value returned from I’d recommend tweaking the example to show reusing the same value you originally passed to 🤖 Prompt for AI Agents |
||
|
|
||
| ## Important notes | ||
|
|
||
| Idempotency keys, even the ones scoped globally, are actually scoped to the task and the environment. This means that you cannot collide with keys from other environments (e.g. dev will never collide with prod), or to other projects and orgs. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tighten error handling: don’t turn all errors into 404s or leak messages
The happy path is good, but the current
try/catch:has a few issues:
error.messageis returned to clients, potentially leaking internal details.ServiceValidationError.A safer pattern is to only treat
ServiceValidationErroras a 4xx and default everything else to 500 with a generic message. For example:(or, if
createActionApiRoutealready handlesServiceValidationErrorglobally, you can simply drop thetry/catchand let it bubble).This keeps client semantics accurate and avoids over‑exposing internal error messages.
🤖 Prompt for AI Agents