From cb1e5a585e1876a18b76dc17d1d3ead7f8c1c265 Mon Sep 17 00:00:00 2001 From: Yggdrasil Date: Wed, 14 Oct 2026 01:58:10 -0400 Subject: [PATCH] fix(api): invalid state transitions surface as 409, not an opaque 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InvalidBountyTransitionError + InvalidMilestoneTransitionError extended plain Error — the global filter sent them down the internal-error path as a generic 500. A claim of an already-claimed bounty (or any bad transition) is a client-facing state-conflict — now extends HttpException(409). Resolves #579 --- src/bounties/bounty-state-machine.ts | 16 ++++++++++++++-- src/milestones/milestone-state-machine.ts | 20 ++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/bounties/bounty-state-machine.ts b/src/bounties/bounty-state-machine.ts index b78b0714..f32edb08 100644 --- a/src/bounties/bounty-state-machine.ts +++ b/src/bounties/bounty-state-machine.ts @@ -40,9 +40,21 @@ export const BOUNTY_TRANSITIONS: Record = { [BountyStatus.EXPIRED]: [BountyStatus.REFUNDED], }; -export class InvalidBountyTransitionError extends Error { +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * A state-transition rejection is a CLIENT-facing error — the bounty's + * current state conflicts with the requested transition (e.g. claiming a + * bounty already claimed). Extending HttpException(409) surfaces it as a real + * HTTP status instead of an opaque 500 (#579) — a plain Error would hit the + * global filter's internal-error path and leak nothing useful. + */ +export class InvalidBountyTransitionError extends HttpException { constructor(from: BountyStatus, to: BountyStatus) { - super(`Cannot transition bounty from "${from}" to "${to}"`); + super( + `Cannot transition bounty from "${from}" to "${to}"`, + HttpStatus.CONFLICT, + ); this.name = 'InvalidBountyTransitionError'; } } diff --git a/src/milestones/milestone-state-machine.ts b/src/milestones/milestone-state-machine.ts index 05a7e287..b487881b 100644 --- a/src/milestones/milestone-state-machine.ts +++ b/src/milestones/milestone-state-machine.ts @@ -21,18 +21,30 @@ export const MILESTONE_TRANSITIONS: Record = [MilestoneStatus.CLOSED]: [], }; -export class InvalidMilestoneTransitionError extends Error { +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** A milestone transition-rejection is a client-facing 409, not a 500 (#579). */ +export class InvalidMilestoneTransitionError extends HttpException { constructor(from: MilestoneStatus, to: MilestoneStatus) { - super(`Cannot transition milestone from "${from}" to "${to}"`); + super( + `Cannot transition milestone from "${from}" to "${to}"`, + HttpStatus.CONFLICT, + ); this.name = 'InvalidMilestoneTransitionError'; } } -export function canTransition(from: MilestoneStatus, to: MilestoneStatus): boolean { +export function canTransition( + from: MilestoneStatus, + to: MilestoneStatus, +): boolean { return MILESTONE_TRANSITIONS[from]?.includes(to) ?? false; } -export function assertTransition(from: MilestoneStatus, to: MilestoneStatus): void { +export function assertTransition( + from: MilestoneStatus, + to: MilestoneStatus, +): void { if (!canTransition(from, to)) { throw new InvalidMilestoneTransitionError(from, to); }