Blog

All posts
John Damask · 2026-02-22
devloginfrastructurecost-trackings3domain

Daily Rate Limit

Each PDF processing job costs real money (Claude API + GitHub API), so the app needed a daily cap to prevent runaway costs or abuse. The initial implementation used an atomic DynamoDB counter with a date-keyed item (RATE_LIMIT#2026-02-22) in the jobs table, but after deploying and testing, this approach felt wrong -- mixing rate limit bookkeeping with actual job records in the same table was messy.

Reworked the approach: the daily limit now lives in SSM Parameter Store (default 20), and the upload Lambda counts today's jobs by scanning DynamoDB records with a created_date field. This is cleaner because the limit is adjustable in Parameter Store without redeploying, and job records are the source of truth for the count. The created_date field was added to every job record -- something that should have been there from the start.

Hit a DynamoDB API gotcha along the way: if_not_exists() is only valid in UpdateExpression, not in ConditionExpression. The fix was attribute_not_exists(request_count) OR request_count < :limit, though this became moot when the atomic counter was replaced entirely.

The frontend handles 429 responses with a specific message ("Daily limit reached -- please try again tomorrow") and re-enables the upload button.


API Cost Tracking

Added per-job cost tracking. The Claude API response already includes input_tokens and output_tokens in the usage object -- stream.get_final_message().usage was already being called but the data was being thrown away. Now generate_html() returns a (html, usage) tuple, and the process Lambda calculates costs using Opus 4.6 pricing ($5/MTok input, $25/MTok output) and writes five fields to each job's DynamoDB record: input_tokens, input_tokens_cost, output_tokens, output_tokens_cost, and total_cost.

The frontend displays a cost breakdown below the gist link after processing completes, showing input tokens, output tokens, and total cost. Keeps the operator aware of what each paper costs to process.


Final Polish: Footer and Secrets Descriptions

Two small cleanup tasks to close out the initial build.

Replaced the "Powered by Claude" footer with a proper copyright line ("© 2026 Amroja, LLC") on the left and a link to johndamask.com on the right, using flexbox to keep them apart. Changed the footer from a <p> to a <div> to hold the two elements.

Also added human-readable descriptions to the Secrets Manager secrets in the deploy script. Both the create and update paths now set descriptions, so anyone browsing the AWS console can immediately see what each secret is for without having to trace through code.

With these two changes deployed, all 8 beads issues are closed. The project is feature-complete for its initial release.


S3 Publishing and Custom Domain

Two infrastructure changes in one session.

First, replaced GitHub Gist publishing with direct S3 uploads. Generated HTML pages now go to an S3 bucket at NOWIGETIT/{job_id}.html instead of creating a Gist via the GitHub API. This eliminated the GITHUB_TOKEN requirement, the requests library dependency, and the gistpreview.github.io external dependency. The new s3_publisher.py is 25 lines versus the old gist_publisher.py's 37. Also fixed a tuple unpacking bug in main.py where generate_html() returns (html, usage) but local dev was only capturing html.

Second, added the custom domain nowigetit.us. The deploy script now provisions an ACM certificate with DNS validation (idempotent -- reuses existing cert on re-deploy), and CloudFormation creates a CloudFront distribution fronting the S3 frontend bucket. A CloudFront Function handles www.nowigetit.us → nowigetit.us 301 redirects. The ACM cert is created in deploy.sh rather than CloudFormation to avoid the stack blocking while waiting for DNS validation. CORS and Lambda ALLOWED_ORIGIN env vars updated to allow https://nowigetit.us.

Key architectural decision: CloudFront uses the S3 website endpoint as a custom HTTP origin (no OAC) to avoid changing the S3 bucket configuration. The API stays on the raw API Gateway URL since it's only referenced in config.js.