Skip to content

Commit 14745d3

Browse files
authored
Merge pull request #612 from autostack-art/feature/stellar-wave-536-539
Add Horizon circuit breaker, login metadata, API versioning, and login smoke test
2 parents 14c2d1f + aab81ad commit 14745d3

11 files changed

Lines changed: 282 additions & 10 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ It handles wallet creation, transaction orchestration, fee sponsorship, and on-c
3333

3434
## API Endpoints
3535

36+
All routes below are served under the `/v1` prefix (e.g. `GET /v1/health`). See [docs/API-VERSIONING.md](docs/API-VERSIONING.md) for the versioning strategy.
37+
3638
### Health & Monitoring
3739

3840
#### `GET /health`

docs/API-VERSIONING.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# API Versioning Strategy
2+
3+
This document describes how the Mux backend versions its public HTTP API.
4+
5+
## Current approach: URI path versioning
6+
7+
All routes are served under a global `/v1` prefix, applied once in `src/main.ts`:
8+
9+
```ts
10+
app.setGlobalPrefix('v1');
11+
```
12+
13+
Individual controllers (e.g. `@Controller('auth')`, `@Controller('wallets')`)
14+
declare their resource path only; the version prefix is applied globally so
15+
every route is automatically namespaced (`/v1/auth/authenticate`,
16+
`/v1/wallets`, etc.). Requests made without the `/v1` prefix return `404 Not
17+
Found` — there is no unversioned fallback.
18+
19+
## Why URI versioning
20+
21+
- **Explicit and cache-friendly**: the version is visible in the URL, in logs,
22+
and in reverse-proxy/CDN routing rules, without relying on a header that
23+
intermediaries may strip.
24+
- **Simple for consumers**: partners and the frontend hard-code a base URL
25+
(e.g. `https://api.mux.dev/v1`) rather than needing to set a custom header
26+
on every request.
27+
- **Matches existing NestJS conventions** in this repo — a single
28+
`setGlobalPrefix` call versions every controller without per-route
29+
decorators.
30+
31+
## Introducing a breaking change (`/v2`)
32+
33+
When a change is not backwards compatible:
34+
35+
1. Add the new/changed controllers under a `v2` path (NestJS's built-in
36+
[URI versioning](https://docs.nestjs.com/techniques/versioning) via
37+
`app.enableVersioning({ type: VersioningType.URI })` can be adopted at that
38+
point to run `v1` and `v2` controllers side by side).
39+
2. Keep `/v1` serving the previous behavior until consumers have migrated.
40+
3. Announce the deprecation window for `/v1` in release notes before removal.
41+
42+
## Non-breaking changes
43+
44+
Additive changes (new endpoints, new optional request/response fields) ship
45+
directly under the current `/v1` prefix — no new version is required.
46+
47+
## Health and monitoring endpoints
48+
49+
`/v1/health` and `/v1/ready` follow the same prefix as every other route, so
50+
uptime checks and readiness probes must be configured with the `/v1` path.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Migration: add lastLoginIp and lastLoginUserAgent to User
2+
--
3+
-- Captures the IP address and User-Agent seen on the user's most recent
4+
-- successful authentication, alongside the existing lastLoginAt timestamp.
5+
-- Both columns are nullable so existing rows require no backfill.
6+
7+
ALTER TABLE "User" ADD COLUMN "lastLoginIp" TEXT;
8+
ALTER TABLE "User" ADD COLUMN "lastLoginUserAgent" TEXT;

prisma/schema.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ model User {
102102
/// Last login timestamp
103103
lastLoginAt DateTime?
104104
105+
/// IP address captured on the most recent successful login
106+
lastLoginIp String?
107+
108+
/// User-Agent header captured on the most recent successful login
109+
lastLoginUserAgent String?
110+
105111
/// Operational metadata
106112
createdAt DateTime @default(now())
107113
updatedAt DateTime @updatedAt

src/auth/auth-orchestrator.controller.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Get,
88
Param,
99
Headers,
10+
Req,
1011
Res,
1112
UseGuards,
1213
Query,
@@ -20,7 +21,7 @@ import {
2021
ApiQuery,
2122
ApiHeader,
2223
} from '@nestjs/swagger';
23-
import type { Response } from 'express';
24+
import type { Request, Response } from 'express';
2425
import {
2526
AuthOrchestrator,
2627
type AuthenticationRequest,
@@ -174,11 +175,14 @@ export class AuthOrchestratorController {
174175
async authenticate(
175176
@Body() request: AuthenticationRequest,
176177
@Headers('idempotency-key') idempotencyKey: string | undefined,
178+
@Req() httpRequest: Request,
177179
@Res() response: Response,
178180
): Promise<void> {
179181
const requestWithIdempotency: AuthenticationRequestWithIdempotency = {
180182
...request,
181183
idempotencyKey,
184+
ipAddress: httpRequest.ip,
185+
userAgent: httpRequest.headers['user-agent'],
182186
};
183187

184188
const result = await this.authOrchestrator.handleAuthentication(

src/auth/auth-orchestrator.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface AuthenticationRequest {
2828
displayName?: string;
2929
authProvider?: string;
3030
network?: WalletNetwork;
31+
ipAddress?: string;
32+
userAgent?: string;
3133
}
3234

3335
export class AuthPayloadValidator {
@@ -365,6 +367,8 @@ export class AuthOrchestrator {
365367
email: request.email,
366368
displayName: request.displayName,
367369
authProvider: request.authProvider || 'UNKNOWN',
370+
lastLoginIp: request.ipAddress,
371+
lastLoginUserAgent: request.userAgent,
368372
};
369373

370374
return retryWithBackoff(() =>

src/balance-indexer/stellar-horizon.service.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import {
2+
Injectable,
3+
Logger,
4+
ServiceUnavailableException,
5+
} from '@nestjs/common';
26
import { ConfigService } from '@nestjs/config';
37
import { Server } from 'stellar-sdk';
48
import { Asset, AssetType, BalanceUpdate } from './domain/balance.model';
59
import { RequestContextService } from '../common/request-context/request-context.service';
10+
import {
11+
CircuitBreaker,
12+
CircuitOpenError,
13+
} from '../common/utils/circuit-breaker';
614

715
export interface HorizonBalance {
816
asset_type: string;
@@ -19,6 +27,7 @@ export class StellarHorizonService {
1927
private readonly retryBackoffMs: number;
2028
private readonly retryJitterMs: number;
2129
private readonly server: Server;
30+
private readonly circuitBreaker: CircuitBreaker;
2231

2332
constructor(
2433
private readonly configService: ConfigService,
@@ -44,29 +53,62 @@ export class StellarHorizonService {
4453

4554
this.logger.log(`Initialized Stellar Horizon client: ${this.horizonUrl}`);
4655
this.server = new Server(horizonUrl, { allowHttp: false });
56+
this.circuitBreaker = new CircuitBreaker('stellar-horizon', {
57+
failureThreshold: this.configService.get<number>(
58+
'HORIZON_CIRCUIT_FAILURE_THRESHOLD',
59+
5,
60+
),
61+
resetTimeoutMs: this.configService.get<number>(
62+
'HORIZON_CIRCUIT_RESET_TIMEOUT_MS',
63+
30000,
64+
),
65+
});
4766
this.logger.log(`Initialized Stellar Horizon client: ${horizonUrl}`);
4867
}
4968

5069
/**
51-
* Helper to execute server actions with retry & backoff
70+
* Helper to execute server actions with retry & backoff, guarded by a
71+
* circuit breaker so a degraded Horizon backend fails fast instead of
72+
* queuing up retries (and their backoff delays) on every caller.
5273
*/
5374
private async executeWithRetry<T>(
5475
operation: () => Promise<T>,
5576
opName: string,
5677
): Promise<T> {
78+
const requestId = this.requestContext.getRequestId();
79+
const logPrefix = requestId ? `[${requestId}] ` : '';
80+
81+
try {
82+
this.circuitBreaker.assertClosed();
83+
} catch (error) {
84+
if (error instanceof CircuitOpenError) {
85+
this.logger.warn(
86+
`${logPrefix}Horizon API ${opName} short-circuited: ${error.message}`,
87+
);
88+
throw new ServiceUnavailableException(
89+
'Stellar Horizon is currently unavailable. Please try again shortly.',
90+
);
91+
}
92+
throw error;
93+
}
94+
5795
const maxRetries = this.configService.get<number>('HORIZON_MAX_RETRIES', 3);
5896
let attempt = 0;
5997
while (true) {
6098
try {
61-
return await operation();
99+
const result = await operation();
100+
this.circuitBreaker.recordSuccess();
101+
return result;
62102
} catch (error) {
63103
attempt++;
64104
if (attempt > maxRetries) {
105+
this.circuitBreaker.recordFailure();
65106
throw error;
66107
}
67-
const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 15000);
68-
const requestId = this.requestContext.getRequestId();
69-
const logPrefix = requestId ? `[${requestId}] ` : '';
108+
const delay = Math.min(
109+
1000 * Math.pow(2, attempt) + Math.random() * 1000,
110+
15000,
111+
);
70112
this.logger.warn(
71113
`${logPrefix}Horizon API ${opName} failed (attempt ${attempt}/${maxRetries}). Retrying in ${Math.round(delay)}ms. Error: ${error.message}`,
72114
);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
export enum CircuitState {
2+
CLOSED = 'CLOSED',
3+
OPEN = 'OPEN',
4+
HALF_OPEN = 'HALF_OPEN',
5+
}
6+
7+
export class CircuitOpenError extends Error {
8+
constructor(name: string) {
9+
super(`Circuit breaker "${name}" is open — failing fast`);
10+
this.name = 'CircuitOpenError';
11+
}
12+
}
13+
14+
export interface CircuitBreakerOptions {
15+
/** Consecutive failures required to trip the circuit from CLOSED to OPEN. */
16+
failureThreshold?: number;
17+
/** How long the circuit stays OPEN before allowing a single HALF_OPEN trial call. */
18+
resetTimeoutMs?: number;
19+
}
20+
21+
/**
22+
* Minimal in-memory circuit breaker (no external dependency).
23+
*
24+
* CLOSED: calls pass through; failures are counted, threshold trips to OPEN.
25+
* OPEN: calls fail fast with CircuitOpenError until resetTimeoutMs elapses.
26+
* HALF_OPEN: a single trial call is allowed through; success closes the
27+
* circuit, failure reopens it and resets the cooldown timer.
28+
*/
29+
export class CircuitBreaker {
30+
private state: CircuitState = CircuitState.CLOSED;
31+
private consecutiveFailures = 0;
32+
private openedAt = 0;
33+
private readonly failureThreshold: number;
34+
private readonly resetTimeoutMs: number;
35+
36+
constructor(
37+
private readonly name: string,
38+
options: CircuitBreakerOptions = {},
39+
) {
40+
this.failureThreshold = options.failureThreshold ?? 5;
41+
this.resetTimeoutMs = options.resetTimeoutMs ?? 30000;
42+
}
43+
44+
getState(): CircuitState {
45+
if (
46+
this.state === CircuitState.OPEN &&
47+
Date.now() - this.openedAt >= this.resetTimeoutMs
48+
) {
49+
this.state = CircuitState.HALF_OPEN;
50+
}
51+
return this.state;
52+
}
53+
54+
/** Throws CircuitOpenError if the call should be short-circuited. */
55+
assertClosed(): void {
56+
if (this.getState() === CircuitState.OPEN) {
57+
throw new CircuitOpenError(this.name);
58+
}
59+
}
60+
61+
recordSuccess(): void {
62+
this.consecutiveFailures = 0;
63+
this.state = CircuitState.CLOSED;
64+
}
65+
66+
recordFailure(): void {
67+
this.consecutiveFailures++;
68+
if (
69+
this.state === CircuitState.HALF_OPEN ||
70+
this.consecutiveFailures >= this.failureThreshold
71+
) {
72+
this.state = CircuitState.OPEN;
73+
this.openedAt = Date.now();
74+
}
75+
}
76+
}

src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ async function bootstrap() {
1414
// Attach request logging middleware early in the pipeline
1515
app.use(requestLogger as any);
1616

17+
// All routes are served under /v1. See docs/API-VERSIONING.md for the
18+
// versioning strategy and how future breaking changes will be introduced.
19+
app.setGlobalPrefix('v1');
20+
1721
// Validate incoming requests for DTOs globally
1822
app.useGlobalPipes(
1923
new ValidationPipe({

src/users/idempotent-user.service.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface FindOrCreateUserRequest {
1313
email?: string;
1414
displayName?: string;
1515
authProvider?: string;
16+
lastLoginIp?: string;
17+
lastLoginUserAgent?: string;
1618
}
1719

1820
export interface User {
@@ -23,6 +25,8 @@ export interface User {
2325
status?: UserStatus;
2426
authProvider: string;
2527
lastLoginAt?: Date;
28+
lastLoginIp?: string;
29+
lastLoginUserAgent?: string;
2630
createdAt: Date;
2731
updatedAt: Date;
2832
}
@@ -66,7 +70,14 @@ export class IdempotentUserService {
6670
async findOrCreateUser(
6771
request: FindOrCreateUserRequest,
6872
): Promise<FindOrCreateUserResult> {
69-
const { authId, email, displayName, authProvider = 'UNKNOWN' } = request;
73+
const {
74+
authId,
75+
email,
76+
displayName,
77+
authProvider = 'UNKNOWN',
78+
lastLoginIp,
79+
lastLoginUserAgent,
80+
} = request;
7081

7182
this.logger.log(`Looking up user with authId: ${authId}`);
7283

@@ -80,7 +91,11 @@ export class IdempotentUserService {
8091

8192
const updatedUser = await this.prisma.user.update({
8293
where: { id: existingUser.id },
83-
data: { lastLoginAt: new Date() },
94+
data: {
95+
lastLoginAt: new Date(),
96+
lastLoginIp,
97+
lastLoginUserAgent,
98+
},
8499
});
85100

86101
this.logger.log(
@@ -100,6 +115,8 @@ export class IdempotentUserService {
100115
displayName,
101116
authProvider,
102117
lastLoginAt: new Date(),
118+
lastLoginIp,
119+
lastLoginUserAgent,
103120
status: 'ACTIVE',
104121
},
105122
});
@@ -132,7 +149,11 @@ export class IdempotentUserService {
132149
if (retryUser) {
133150
const updatedRetryUser = await this.prisma.user.update({
134151
where: { id: retryUser.id },
135-
data: { lastLoginAt: new Date() },
152+
data: {
153+
lastLoginAt: new Date(),
154+
lastLoginIp,
155+
lastLoginUserAgent,
156+
},
136157
});
137158

138159
return {
@@ -247,6 +268,8 @@ export class IdempotentUserService {
247268
status: prismaUser.status,
248269
authProvider: prismaUser.authProvider,
249270
lastLoginAt: prismaUser.lastLoginAt,
271+
lastLoginIp: prismaUser.lastLoginIp,
272+
lastLoginUserAgent: prismaUser.lastLoginUserAgent,
250273
createdAt: prismaUser.createdAt,
251274
updatedAt: prismaUser.updatedAt,
252275
};

0 commit comments

Comments
 (0)