Skip to content

Commit ca2906b

Browse files
authored
Merge pull request #13 from devhelmhq/chore/codegen-audit-phase-2
feat(errors): tighten error taxonomy + surface code/requestId
2 parents 47070ca + e2a399e commit ca2906b

33 files changed

Lines changed: 577 additions & 203 deletions

docs/openapi/monitoring-api.json

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10128,8 +10128,7 @@
1012810128
"$ref": "#/components/schemas/TestNotificationPolicyRequest"
1012910129
}
1013010130
}
10131-
},
10132-
"required": true
10131+
}
1013310132
},
1013410133
"responses": {
1013510134
"200": {
@@ -12921,6 +12920,7 @@
1292112920
"Status Data"
1292212921
],
1292312922
"summary": "Get a single service by slug or UUID with current status, components, and recent incidents",
12923+
"description": "When ``summary=true``, the inline ``components`` list is trimmed to groups + showcase leaves + currently-impacted leaves + ungrouped leaves, and a ``componentsSummary`` block is added with the trimmed counts. Powers SSR for vendors with hundreds of components (Snowflake, Cloudflare, DigitalOcean) without OOM-ing the renderer. Default false for full back-compat.",
1292412924
"operationId": "getService",
1292512925
"parameters": [
1292612926
{
@@ -12930,6 +12930,16 @@
1293012930
"schema": {
1293112931
"type": "string"
1293212932
}
12933+
},
12934+
{
12935+
"name": "summary",
12936+
"in": "query",
12937+
"description": "Return a curated subset of components (groups + showcase + impacted + ungrouped) and a componentsSummary block; default false",
12938+
"required": false,
12939+
"schema": {
12940+
"type": "boolean",
12941+
"default": false
12942+
}
1293312943
}
1293412944
],
1293512945
"responses": {
@@ -13032,6 +13042,7 @@
1303213042
"Status Data"
1303313043
],
1303413044
"summary": "List active components for a service with current status and inline uptime",
13045+
"description": "When ``groupId`` is supplied, only direct children of that group are returned — used by the pSEO renderer to lazy-load the leaves under a group that summary mode trimmed. Without ``groupId`` the response includes every active component for the service.",
1303513046
"operationId": "getComponents",
1303613047
"parameters": [
1303713048
{
@@ -13041,6 +13052,16 @@
1304113052
"schema": {
1304213053
"type": "string"
1304313054
}
13055+
},
13056+
{
13057+
"name": "groupId",
13058+
"in": "query",
13059+
"description": "Restrict result to direct children of this group component id",
13060+
"required": false,
13061+
"schema": {
13062+
"type": "string",
13063+
"format": "uuid"
13064+
}
1304413065
}
1304513066
],
1304613067
"responses": {
@@ -21894,6 +21915,35 @@
2189421915
},
2189521916
"description": "A single component position"
2189621917
},
21918+
"ComponentsSummaryDto": {
21919+
"required": [
21920+
"groupComponentCounts",
21921+
"includedCount",
21922+
"totalCount"
21923+
],
21924+
"type": "object",
21925+
"properties": {
21926+
"totalCount": {
21927+
"type": "integer",
21928+
"description": "Total active components for this service across all groups",
21929+
"format": "int32"
21930+
},
21931+
"includedCount": {
21932+
"type": "integer",
21933+
"description": "Number of components actually returned in the inline ``components`` list",
21934+
"format": "int32"
21935+
},
21936+
"groupComponentCounts": {
21937+
"type": "object",
21938+
"additionalProperties": {
21939+
"type": "integer",
21940+
"description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip",
21941+
"format": "int32"
21942+
},
21943+
"description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip"
21944+
}
21945+
}
21946+
},
2189721947
"ComponentStatusDto": {
2189821948
"required": [
2189921949
"id",
@@ -22239,8 +22289,7 @@
2223922289
"CreateEnvironmentRequest": {
2224022290
"required": [
2224122291
"name",
22242-
"slug",
22243-
"isDefault"
22292+
"slug"
2224422293
],
2224522294
"type": "object",
2224622295
"properties": {
@@ -22269,7 +22318,8 @@
2226922318
},
2227022319
"isDefault": {
2227122320
"type": "boolean",
22272-
"description": "Whether this is the default environment for new monitors"
22321+
"description": "Whether this is the default environment for new monitors (default: false)",
22322+
"nullable": true
2227322323
}
2227422324
}
2227522325
},
@@ -22992,11 +23042,10 @@
2299223042
"subscribedEvents": {
2299323043
"minItems": 1,
2299423044
"type": "array",
22995-
"description": "Event types to deliver, e.g. monitor.created, incident.resolved",
23045+
"description": "Event types to deliver",
2299623046
"items": {
2299723047
"minLength": 1,
2299823048
"type": "string",
22999-
"description": "Event types to deliver, e.g. monitor.created, incident.resolved",
2300023049
"enum": [
2300123050
"monitor.created",
2300223051
"monitor.updated",
@@ -23895,6 +23944,7 @@
2389523944
},
2389623945
"ErrorResponse": {
2389723946
"required": [
23947+
"code",
2389823948
"message",
2389923949
"status",
2390023950
"timestamp"
@@ -23907,6 +23957,11 @@
2390723957
"format": "int32",
2390823958
"example": 404
2390923959
},
23960+
"code": {
23961+
"type": "string",
23962+
"description": "Coarse machine-readable error category (e.g. NOT_FOUND, RATE_LIMITED); stable per status",
23963+
"example": "NOT_FOUND"
23964+
},
2391023965
"message": {
2391123966
"type": "string",
2391223967
"description": "Human-readable error message; safe to surface to end users",
@@ -23917,13 +23972,21 @@
2391723972
"description": "Server time when the error was produced (epoch milliseconds)",
2391823973
"format": "int64",
2391923974
"example": 1737302400000
23975+
},
23976+
"requestId": {
23977+
"type": "string",
23978+
"description": "Opaque per-request id; same value as the X-Request-Id response header. Use in support tickets.",
23979+
"nullable": true,
23980+
"example": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a"
2392023981
}
2392123982
},
2392223983
"description": "Uniform error envelope returned for every non-2xx response",
2392323984
"example": {
2392423985
"status": 404,
23986+
"code": "NOT_FOUND",
2392523987
"message": "Monitor not found",
23926-
"timestamp": 1737302400000
23988+
"timestamp": 1737302400000,
23989+
"requestId": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a"
2392723990
}
2392823991
},
2392923992
"EscalationChain": {
@@ -25443,7 +25506,7 @@
2544325506
"properties": {
2544425507
"type": {
2544525508
"type": "string",
25446-
"description": "Rule type, e.g. severity_gte, monitor_id_in, region_in",
25509+
"description": "Rule type used to evaluate incidents and status events",
2544725510
"enum": [
2544825511
"severity_gte",
2544925512
"monitor_id_in",
@@ -25452,7 +25515,8 @@
2545225515
"monitor_type_in",
2545325516
"service_id_in",
2545425517
"resource_group_id_in",
25455-
"component_name_in"
25518+
"component_name_in",
25519+
"monitor_tag_in"
2545625520
]
2545725521
},
2545825522
"value": {
@@ -28172,6 +28236,14 @@
2817228236
"$ref": "#/components/schemas/ServiceComponentDto"
2817328237
}
2817428238
},
28239+
"componentsSummary": {
28240+
"nullable": true,
28241+
"allOf": [
28242+
{
28243+
"$ref": "#/components/schemas/ComponentsSummaryDto"
28244+
}
28245+
]
28246+
},
2817528247
"uptime": {
2817628248
"nullable": true,
2817728249
"allOf": [
@@ -32541,7 +32613,6 @@
3254132613
"nullable": true,
3254232614
"items": {
3254332615
"type": "string",
32544-
"description": "Replace subscribed events; null preserves current",
3254532616
"enum": [
3254632617
"monitor.created",
3254732618
"monitor.updated",
@@ -32952,4 +33023,4 @@
3295233023
}
3295333024
}
3295433025
}
32955-
}
33026+
}

src/commands/alert-channels/test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ export default class AlertChannelsTest extends Command {
1212
async run() {
1313
const {args, flags} = await this.parse(AlertChannelsTest)
1414
const client = buildClient(flags)
15-
const resp = (await checkedFetch(
15+
const resp = await checkedFetch(
1616
client.POST('/api/v1/alert-channels/{id}/test', {params: {path: {id: args.id}}}),
17-
)) as {data?: {success?: boolean}}
18-
this.log(resp.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.')
17+
)
18+
this.log(resp?.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.')
1919
}
2020
}

src/commands/auth/context/delete.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Command, Args} from '@oclif/core'
22
import {globalFlags} from '../../../lib/base-command.js'
33
import {removeContext} from '../../../lib/auth.js'
4+
import {EXIT_CODES} from '../../../lib/errors.js'
45

56
export default class AuthContextDelete extends Command {
67
static description = 'Delete an auth context'
@@ -11,7 +12,9 @@ export default class AuthContextDelete extends Command {
1112
async run() {
1213
const {args} = await this.parse(AuthContextDelete)
1314
const ok = removeContext(args.name)
14-
if (!ok) { this.error(`Context '${args.name}' not found.`, {exit: 1}) }
15+
if (!ok) {
16+
this.error(`Context '${args.name}' not found.`, {exit: EXIT_CODES.VALIDATION})
17+
}
1518
this.log(`Context '${args.name}' deleted.`)
1619
}
1720
}

src/commands/auth/context/use.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Command, Args} from '@oclif/core'
22
import {globalFlags} from '../../../lib/base-command.js'
33
import {setCurrentContext} from '../../../lib/auth.js'
4+
import {EXIT_CODES} from '../../../lib/errors.js'
45

56
export default class AuthContextUse extends Command {
67
static description = 'Switch to a different auth context'
@@ -11,7 +12,9 @@ export default class AuthContextUse extends Command {
1112
async run() {
1213
const {args} = await this.parse(AuthContextUse)
1314
const ok = setCurrentContext(args.name)
14-
if (!ok) { this.error(`Context '${args.name}' not found.`, {exit: 1}) }
15+
if (!ok) {
16+
this.error(`Context '${args.name}' not found.`, {exit: EXIT_CODES.VALIDATION})
17+
}
1518
this.log(`Switched to context '${args.name}'.`)
1619
}
1720
}

src/commands/auth/login.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Command, Flags} from '@oclif/core'
22
import {globalFlags} from '../../lib/base-command.js'
33
import {createApiClient, checkedFetch, apiGet} from '../../lib/api-client.js'
44
import {saveContext, resolveApiUrl} from '../../lib/auth.js'
5+
import {EXIT_CODES} from '../../lib/errors.js'
56
import * as readline from 'node:readline'
67

78
export default class AuthLogin extends Command {
@@ -25,18 +26,12 @@ export default class AuthLogin extends Command {
2526
const client = createApiClient({baseUrl: apiUrl, token})
2627

2728
try {
28-
const resp = (await checkedFetch(client.GET('/api/v1/auth/me'))) as {
29-
data?: {
30-
organization?: {name?: string; id?: string | number}
31-
key?: {name?: string}
32-
plan?: {tier?: string}
33-
}
34-
}
35-
if (!resp.data) {
29+
const resp = await checkedFetch(client.GET('/api/v1/auth/me'))
30+
const me = resp?.data
31+
if (!me) {
3632
throw new Error('Empty response')
3733
}
3834

39-
const me = resp.data
4035
saveContext({name: flags.name, apiUrl, token}, true)
4136
this.log('')
4237
this.log(` Authenticated successfully.`)
@@ -57,7 +52,10 @@ export default class AuthLogin extends Command {
5752
this.log(` Authenticated successfully.`)
5853
this.log(` Context '${flags.name}' saved to ~/.devhelm/contexts.json`)
5954
} catch {
60-
this.error('Invalid token. Authentication failed.', {exit: 2})
55+
// Token rejected by the API. Surface as DevhelmAuthError-equivalent
56+
// (exit 11 — same as any other 4xx) so scripts can branch on the
57+
// canonical API error code instead of a bespoke "auth login" exit.
58+
this.error('Invalid token. Authentication failed.', {exit: EXIT_CODES.API})
6159
}
6260
}
6361

src/commands/auth/token.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Command} from '@oclif/core'
22
import {globalFlags} from '../../lib/base-command.js'
33
import {resolveToken} from '../../lib/auth.js'
4+
import {EXIT_CODES} from '../../lib/errors.js'
45

56
export default class AuthToken extends Command {
67
static description = 'Print the current API token'
@@ -10,7 +11,9 @@ export default class AuthToken extends Command {
1011
async run() {
1112
const {flags} = await this.parse(AuthToken)
1213
const token = flags['api-token'] || resolveToken()
13-
if (!token) { this.error('No token found. Run `devhelm auth login` first.', {exit: 2}) }
14+
if (!token) {
15+
this.error('No token found. Run `devhelm auth login` first.', {exit: EXIT_CODES.VALIDATION})
16+
}
1417
this.log(token)
1518
}
1619
}

src/commands/dependencies/track.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ export default class DependenciesTrack extends Command {
1111
async run() {
1212
const {args, flags} = await this.parse(DependenciesTrack)
1313
const client = buildClient(flags)
14-
const resp = (await checkedFetch(
14+
const resp = await checkedFetch(
1515
client.POST('/api/v1/service-subscriptions/{slug}', {params: {path: {slug: args.slug}}}),
16-
)) as {data?: {name?: string}}
17-
this.log(`Now tracking '${resp.data?.name}' as a dependency.`)
16+
)
17+
this.log(`Now tracking '${resp?.data?.name ?? args.slug}' as a dependency.`)
1818
}
1919
}

src/commands/deploy/force-unlock.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Command, Flags} from '@oclif/core'
22
import {createApiClient, apiDelete} from '../../lib/api-client.js'
33
import {resolveToken, resolveApiUrl} from '../../lib/auth.js'
4+
import {DevhelmApiError, EXIT_CODES} from '../../lib/errors.js'
45
import {urlFlag} from '../../lib/validators.js'
56

67
export default class DeployForceUnlock extends Command {
@@ -40,7 +41,10 @@ export default class DeployForceUnlock extends Command {
4041

4142
const token = flags['api-token'] ?? resolveToken()
4243
if (!token) {
43-
this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1})
44+
this.error(
45+
'No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.',
46+
{exit: EXIT_CODES.VALIDATION},
47+
)
4448
}
4549

4650
const client = createApiClient({
@@ -53,12 +57,15 @@ export default class DeployForceUnlock extends Command {
5357
await apiDelete(client, '/api/v1/deploy/lock/force')
5458
this.log('Deploy lock released.')
5559
} catch (err) {
56-
const msg = err instanceof Error ? err.message : String(err)
57-
if (msg.includes('404') || msg.includes('Not Found')) {
60+
// Branch on the typed error rather than substring-matching the message:
61+
// the canonical DevhelmApiError carries a real HTTP status, so 404
62+
// ("no lock to release") is unambiguous and we exit successfully.
63+
if (err instanceof DevhelmApiError && err.status === 404) {
5864
this.log('No active deploy lock found.')
5965
return
6066
}
61-
this.error(`Failed to release lock: ${msg}`, {exit: 1})
67+
const msg = err instanceof Error ? err.message : String(err)
68+
this.error(`Failed to release lock: ${msg}`, {exit: EXIT_CODES.API})
6269
}
6370
}
6471
}

0 commit comments

Comments
 (0)