Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/routes/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ import { getCorrelationId } from "../middleware/correlation";
import { logger } from "../config/logger";
import { getRequestId } from "../lib/requestContext";

// In-flight request tracking for graceful shutdown drain
let inFlightFingerprintRequests = 0;

export async function drainFingerprintRequests(timeoutMs = 10000): Promise<void> {
const start = Date.now();
if (inFlightFingerprintRequests === 0) {
logger.info("No in-flight /api/fingerprint requests to drain");
return;
}

logger.info({ inFlight: inFlightFingerprintRequests }, "Draining in-flight /api/fingerprint requests...");

while (inFlightFingerprintRequests > 0) {
if (Date.now() - start > timeoutMs) {
logger.warn({ inFlight: inFlightFingerprintRequests }, "Timeout waiting for /api/fingerprint requests to drain");
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}

if (inFlightFingerprintRequests === 0) {
logger.info("Successfully drained all /api/fingerprint requests");
}
}

export const fingerprintRouter = Router();

/**
Expand All @@ -45,6 +70,7 @@ fingerprintRouter.get(
"/",
fingerprintRateLimiter,
(req: Request, res: Response, next: NextFunction): void => {
inFlightFingerprintRequests++;
const correlationId = getCorrelationId() ?? "unknown";
const reqId = getRequestId() ?? "unknown";

Expand Down Expand Up @@ -76,6 +102,8 @@ fingerprintRouter.get(
"fingerprint_route_error",
);
next(err);
} finally {
inFlightFingerprintRequests--;
}
},
);
70 changes: 58 additions & 12 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,23 +238,69 @@ usersRouter.get(
(res.locals.correlationId as string | undefined) ?? getRequestId();

try {
stellarAddressSchema.parse(address);
} catch {
return res.status(400).json({ error: { code: "invalid_address" } });
const userId = req.user!.id;
const result = await getCurrentUserProfile(userId);

if (!result.ok) {
throw result.error;
}

const profile = result.value;
logger.info(
{
correlationId,
userId,
stellarAddress: profile.stellarAddress,
...profile.totals,
},
"user_me_profile_loaded",
);

// Strong ETag on the profile payload; 304 if client already has it.
const responsePayload = { data: profile };
if (conditionalGet(responsePayload, req, res)) return;
return res.json(responsePayload);
} catch (e) {
return next(e);
}
},
);

const querySchema = z.object({
status: z.enum(["pending", "confirmed", "won", "lost", "claimed"]).optional(),
cursor: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
.optional(),
limit: z.coerce.number().int().min(1).max(100),
});
// ---------------------------------------------------------------------------
// GET /api/users/:address/predictions
// ---------------------------------------------------------------------------

const query = querySchema.parse({ status, cursor, limit });
/**
* Returns a cursor-paginated list of predictions for the given Stellar address.
*
* Path parameters:
* - :address — a valid 56-char Stellar G-address
*
* Query parameters:
* - status (optional) — filter by prediction status enum
* - cursor (optional) — opaque base64url token from the previous page's `nextCursor`
* - limit (optional, default 20, max 100) — page size
*
* Response:
* { data: UserPredictionRow[], nextCursor: string | null }
*
* Caching:
* Strong ETag on the page payload; clients may revalidate with If-None-Match
* and receive 304 Not Modified when the page is unchanged.
*
* Errors:
* 400 invalid_address — path param is not a valid G… Stellar address
* 400 validation_error — query params fail the zod schema
* 404 not_found — no user row for that address
*/
usersRouter.get(
"/:address/predictions",
usersRateLimit,
async (req: Request, res: Response, next: NextFunction) => {
// Prefer the access-log correlation ID; fall back to ALS for non-route callers.
const correlationId =
(res.locals.correlationId as string | undefined) ?? getRequestId();
const reqId = correlationId;

try {
// Validate the path parameter :address at the route boundary before touching the DB.
Expand Down
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { startSlowQueryAlerter } from "./workers/slowQueryAlerter";
import { startPredictionsConfirmer } from "./workers/predictionsConfirmer";
import { drainSearchRequests } from "./routes/search";
import { drainExportsRequests } from "./routes/exports";
import { drainFingerprintRequests } from "./routes/fingerprint";

const app = createApp();
let webhookWorker: WebhookWorker | null = null;
Expand Down Expand Up @@ -47,6 +48,8 @@ connectWithRetry()
await drainSearchRequests(4000);
// Ensure in-flight /api/exports requests finish
await drainExportsRequests(4000);
// Ensure in-flight /api/fingerprint requests finish
await drainFingerprintRequests(4000);

stopScheduler();
await closeDb();
Expand Down
13 changes: 12 additions & 1 deletion tests/fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

import request from "supertest";
import { createApp } from "../src/index";
import { drainFingerprintRequests } from "../src/routes/fingerprint";
import {
normalizeContentType,
normalizeAccept,
Expand Down Expand Up @@ -451,7 +452,17 @@ describe("GET /api/fingerprint", () => {
});
});

// ── 35: fingerprintMiddleware error handling ────────────────────────────
// ── 35: fingerprintRoute drain ───────────────────────────────────────

describe("drainFingerprintRequests()", () => {
it("returns immediately when no in-flight requests", async () => {
const start = Date.now();
await drainFingerprintRequests(1000);
expect(Date.now() - start).toBeLessThan(100);
});
});

// ── 36: fingerprintMiddleware error handling ────────────────────────────

describe("fingerprintMiddleware error handling", () => {
it("calls next() and does not throw when an internal error occurs", () => {
Expand Down