Holidays and business days API in Go
Low-latency Go API for holiday and business-day calculations across 60 countries, with reviewed yearly data, tiered rate limits and caching.
gonet-httpgo-embedlrurate-limiting
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add holidays-and-business-days-api-in-go-c46a18CLAUDE.md# Holidays and business days API (Go)
## Purpose
Public holidays and business-day math for 60 countries. Low-latency, read-only JSON API.
Endpoints:
- `GET /v1/business-day`
- `GET /v1/add-business-days`
- `GET /v1/holidays`
Free tier is rate limited by IP. Paid tiers are limited by request volume. No UI, no billing UI, no automatic data sourcing.
## Stack
- Go (latest stable), stdlib first.
- `net/http` with 1.22+ ServeMux patterns. No web framework.
- Yearly data embedded with `go:embed` and loaded into memory at startup.
- Tests: table-driven, `testing` package only.
## Commands
- Build: `go build ./...`
- Test: `go test ./... -race`
- Vet: `go vet ./...`
- Lint: `golangci-lint run`
- Benchmarks: `go test -bench . -benchmem ./internal/calendar`
- Run API: `go run ./cmd/api`
- Check data: `go run ./cmd/datacheck`
## Layout
- `cmd/api`: server entry point.
- `cmd/datacheck`: validates all data files.
- `internal/calendar`: pure date logic, no I/O.
- `internal/data`: embedded loader and validation.
- `internal/api`: HTTP handlers.
- `internal/ratelimit`: token buckets.
- `internal/cache`: in-process LRU.
- `data/<country>/<year>.json`: reviewed yearly files.
## Data rules
- Yearly files are reviewed by hand.
- Never invent or auto-edit holiday dates.
- Change files under `data/` only when asked.
- Every file must pass `cmd/datacheck`: schema, sorted dates, valid region codes, no duplicates.
- Weekend days, regional holidays and observed-day shifts are stored explicitly in data. Do not compute them.
- If a holiday date is missing, ask the user. Do not fill it from memory.
## Business day semantics
- Weekend definition is per country. Some countries use Fri-Sat.
- Regional holidays are filtered by the `region` parameter.
- Add-N-days rule (confirm in SPEC.md if unclear):
- Negative N moves backward.
- N=0 returns the input date unchanged.
- Each step skips non-business days.
- Document these rules in the `internal/calendar` package comment and cover them with table tests.
## Performance rules
- Avoid allocations on the hot path where possible.
- Build a per country-year day index (bitset or sorted slice) at load time.
- No file or database reads per request.
- Benchmarks must not regress. Compare with `bench/baseline.txt`.
## Caching rules
- Responses are deterministic per data version.
- Set `Cache-Control` and `ETag`. The ETag includes the data version.
- Keep an in-process LRU for computed results.
- Design for a CDN in front of the API.
## Rate limits
- Token bucket per API key.
- Free tier by IP when no key is sent.
- Tier limits live in config. Never hardcode them in handlers.
- Return 429 with `Retry-After` and standard `RateLimit` headers.
## Conventions
- Dates are ISO 8601 `YYYY-MM-DD`, civil dates with no time zone.
- Errors are wrapped with `%w`.
- Error JSON shape: `{"error":{"code":"...","message":"..."}}`.
## Gotchas
- Time zones must not shift civil dates. Use `time.Date` in UTC for date-only math.
- Check leap years and year boundaries in add-business-days.
- A year without data returns 404 with code `year_not_available`, not an empty list.
## Secrets
- API keys and billing credentials come only from environment variables.
- Use placeholders such as `YOUR_API_KEY` in docs and examples.
- Never commit `.env`.
## Workflow
1. Explore the code first.
2. Write a short plan.
3. Implement.
4. Run the verify skill before saying done.
5. Use `/clear` between unrelated tasks.
Review criteria for `/code-review`:
@REVIEW.md
.claude/agents/test-runner.md---
name: test-runner
description: Runs the Go test suite with the race detector and returns only failing tests. Use after code changes when the main session should not read full test output.
model: haiku
tools: Bash, Read, Grep
disallowedTools: Edit, Write
maxTurns: 8
---
You run the Go test suite for this project and report failures. You never edit or create files.
## Steps
1. From the repository root, run `go test ./... -race -count=1`.
2. If the command succeeds, reply with one line: `PASS: all packages`.
3. If it fails, collect each failing test:
- the package and test name (for example `internal/calendar TestAddBusinessDays/negative_n`),
- the file:line where the failure was reported,
- a short error excerpt, at most 3 lines.
4. For a race detector report, give the two conflicting stack locations (file:line) and the test name. Do not paste the full trace.
5. If the build fails before tests run, return the compiler errors with file:line.
## Output
- Start with `FAIL:` and the count of failing tests.
- List each failure as one block: name, file:line, excerpt.
- Do not include passing tests, logs, or suggested fixes.
- Do not read source files beyond what is needed to confirm a file:line reference.
.claude/settings.json{
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(go vet:*)",
"Bash(go run ./cmd/datacheck:*)",
"Bash(golangci-lint run:*)",
"Bash(gofmt:*)",
"Bash(git status)",
"Bash(git diff:*)"
],
"ask": [
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(go get:*)",
"Bash(go mod:*)",
"Bash(docker:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(rm -rf:*)",
"Bash(curl:*|sh)",
"Bash(wget:*|sh)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.go) gofmt -w \"$f\";; esac"
}
]
}
]
}
}
.claude/skills/add-country/SKILL.md---
name: add-country
description: Add a country's holiday data from a user-supplied, hand-reviewed source file. Use only when the user explicitly invokes it.
disable-model-invocation: true
---
# Add country
This skill runs only when the user invokes it. Never fill in holidays from memory.
## Inputs
- Country code (ISO 3166-1 alpha-2, lowercase for the folder, for example `de`).
- Year.
- The hand-reviewed source file from the user. If the user has not supplied it, stop and ask for it.
## Steps
1. Place the source file at `data/<cc>/<year>.json`.
2. Validate it against the schema in `SPEC.md`:
- Required fields: `country`, `year`, weekend days, `holidays`, `reviewed_by`, `reviewed_at`.
- Each holiday has `date`, `name`, `local_name`, `type`, and `regions[]`. `observed_for` is optional.
3. Run `go run ./cmd/datacheck`.
- It must exit zero.
- It checks schema, sorted dates, no duplicates, valid region codes, and a weekend definition.
4. Add golden test cases supplied by the user.
- Do not invent expected results. Use only the cases the user gives.
- Put them with the existing table-driven tests in `internal/calendar`.
5. Bump the data version so ETags change.
- Use the version source in `internal/data`.
6. Run the `verify` skill before reporting done.
## Rules
- Never fill missing holidays from memory. If a date, name, or region is missing, ask the user.
- Never invent or silently edit a holiday date.
- Do not change files for other countries or years.
## Checks for this country
- Confirm the weekend definition for the country. Some countries use Friday and Saturday. Ask the user if unsure.
- Confirm every region code is one the project recognizes. Add no new codes without the user's approval.
- Confirm any observed-day shifts are stored explicitly in the file as `observed_for`. Do not compute them.
## Report
- Files added or changed.
- Datacheck and test results.
- Open questions for the user, such as missing holidays or unclear regions.
.claude/skills/bench-compare/SKILL.md---
name: bench-compare
description: Run internal benchmarks and compare them with the saved baseline to catch latency or allocation regressions on the hot path.
---
# Bench compare
Use this skill when the calendar logic, the data index, the cache, or any hot path changes.
## Steps
1. Run the benchmarks five times and save to a temp file:
```
go test -bench . -benchmem -count=5 ./internal/... > /tmp/bench-new.txt
```
2. If `benchstat` is installed, compare against the baseline:
```
benchstat bench/baseline.txt /tmp/bench-new.txt
```
If it is not installed, compare the medians by eye and say that benchstat was not used.
## Regression thresholds
- Fail if ns/op rises more than 10 percent on any hot-path benchmark.
- Fail if allocs/op increases on any hot-path benchmark.
- Lookup benchmarks must keep allocs/op at 0.
- Report any other change, even if it passes.
## Updating the baseline
- Never overwrite `bench/baseline.txt` on your own.
- Update it only when the user approves the new numbers.
- When approved, copy `/tmp/bench-new.txt` to `bench/baseline.txt` and say so in the report.
## Report
Give a small table with one row per benchmark:
| Benchmark | Baseline ns/op | New ns/op | Change | Allocs/op (base to new) | Result |
|---|---|---|---|---|---|
- Result is `pass`, `fail`, or `new` (no baseline row yet).
- End with a one-line verdict: pass or fail, and which thresholds failed.
.claude/skills/verify/SKILL.md---
name: verify
description: Run after any code or data change to prove the Go API still builds, passes tests, and serves correct headers before reporting done.
---
# Verify
Run these steps in order from the repo root. Stop at the first failing step, fix it, and start again from step 1.
## Steps
1. Format check: `gofmt -l .`
- Must print nothing. If it prints file names, run `gofmt -w` on those files and rerun.
2. Vet: `go vet ./...`
3. Lint: `golangci-lint run`
- Skip only if `golangci-lint` is not installed. Say so in the report.
4. Tests: `go test ./... -race -count=1`
5. Data check: `go run ./cmd/datacheck`
- Run only when `data/` or `internal/data` changed.
6. Benchmarks: `go test -bench . -benchmem -run '^$' ./internal/calendar`
- Run only when `internal/calendar` or a hot path changed.
- Compare ns/op and allocs/op with the numbers in `bench/baseline.txt`.
- Fail if ns/op rises more than 10 percent or allocs/op increases.
7. Smoke run:
- Start the server in the background: `go run ./cmd/api &`
- Wait until it listens, then check:
- `curl -s localhost:PORT/healthz` returns success.
- One request per endpoint:
- `GET /v1/business-day`
- `GET /v1/add-business-days`
- `GET /v1/holidays`
- Each response has an `ETag` and a `Cache-Control` header.
- Stop the server before reporting.
- Use the port from config. Do not hardcode an API key. Use `YOUR_API_KEY` from an env var if a key is needed.
## Reporting
- Report only failures.
- For each failure, give the exact command that failed and the short relevant output.
- Do not say "done" or "all checks pass" unless every required step passed.
- Steps skipped by the rules above are not failures. List them as skipped.
INSTALL.md# Install
## Prerequisites
- Go, latest stable release
- git
- golangci-lint
- Claude Code 2.1.296 or newer (check with `claude --version`)
## Where each file goes
Create the repo, then copy the files:
| File | Location |
|---|---|
| CLAUDE.md, SPEC.md, PLAN.md, REVIEW.md, INSTALL.md | repo root |
| settings.json | `.claude/settings.json` |
| verify, add-country, bench-compare | `.claude/skills/<name>/SKILL.md` |
| test-runner | `.claude/agents/test-runner.md` |
Put personal overrides in `.claude/settings.local.json`. Git ignores this file, so it is not committed.
## Setup steps
1. Create the repo and set up the module:
```
go mod init YOUR_MODULE_PATH
git init
```
2. Install the linter:
```
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
```
3. Install the plugins:
```
claude plugin install code-review@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
```
4. Start Claude Code in the repo:
```
claude
```
5. Check the skills with `/skill-doctor`.
## First session
Do not run `/init`. CLAUDE.md already exists.
Paste the kickoff prompt from PLAN.md to begin Phase 1.
## Working through the phases
- Run `/clear` before each phase. Then paste that phase's `/goal` prompt from PLAN.md.
- Run `/clear` between unrelated tasks.
- Run `/compact` at natural breaks.
- Run `/usage` to see where tokens went.
## Secrets
- Keep API keys and billing credentials in environment variables, such as `YOUR_API_KEY`.
- Never commit them. Never commit `.env` files.
PLAN.md# Build plan: holidays and business days API
Six phases. Each phase ends with a `/goal` prompt. Run `/clear` before each phase, and keep Opus 5.5 at medium effort.
Use `ultrathink` only in two places: the add-business-days design (Phase 2) and the rate limiter design (Phase 4).
## Ground rules for every phase
- Holiday dates come only from source files you supply. If a date is missing, Claude marks it `TODO_REVIEW` and asks. It never fills dates from memory.
- Do not edit CLAUDE.md or SPEC.md without asking.
- Stdlib first. Add a dependency only with your approval.
- Benchmark results go to `bench/phaseN.txt`. Only update `bench/baseline.txt` when you approve.
- Claude prints the output of each check command, because `/goal` judges only what appears in the conversation.
## Phase 1: Data model and loader
Goal: schema, embedded loader, and `cmd/datacheck`, with three sample countries.
- Sample data: US, DE, AE, one year each, from files you supply.
- Loader: `go:embed` of `data/`, schema validation, per country-year index for O(1) lookup at startup.
- `cmd/datacheck`: fails on schema errors, unsorted or duplicate dates, invalid region codes, and missing weekend definitions.
- Tests: table-driven, covering loader success, each datacheck failure, and a missing-year lookup.
- Done when: `go vet ./...`, `go test ./... -race -count=1`, and `go run ./cmd/datacheck` pass, and the lookup benchmark shows 0 allocs/op.
```
/goal Complete Phase 1 of PLAN.md: data model and loader for the holidays API.
End state:
(1) data/US, data/DE, data/AE each hold one year of JSON matching the SPEC.md schema. Holiday dates come only from source files I supplied. Any missing date is marked with a TODO_REVIEW field and is not treated as a real date.
(2) internal/data embeds data/ with go:embed, validates the schema, and builds a per country-year index at startup for O(1) date lookup.
(3) cmd/datacheck exits non-zero on schema errors, unsorted or duplicate dates, invalid region codes, or missing weekend definitions, and prints a clear message for each.
(4) Table-driven tests cover loader success, each datacheck failure, and a missing-year lookup.
Check: `gofmt -l .` prints nothing; `go vet ./...`, `go test ./... -race -count=1`, and `go run ./cmd/datacheck` all pass; a lookup benchmark reports 0 allocs/op. Print the output of each.
Constraints: stdlib only, no new dependencies, no HTTP code, do not edit CLAUDE.md or SPEC.md without asking. Read SPEC.md and CLAUDE.md, write a short plan, then implement.
Stop after 35 turns and report what is done, what is left, and any questions about the data.
```
## Phase 2: Calendar logic
Goal: pure date logic in `internal/calendar`, with no I/O.
- Functions: `IsBusinessDay`, `AddBusinessDays` (including negative N and N=0, documented per CLAUDE.md), and `ListHolidays` with region filter.
- Use `time.Date` in UTC for date-only math, so time zones never shift civil dates.
- Cover leap years and year boundaries.
- Tests: table-driven, plus property tests (for example, adding N then subtracting N returns to a business day).
- Benchmarks for the calendar hot path, saved to `bench/phase2.txt`.
- Done when: `go test ./... -race -count=1` and `go vet ./...` pass, and benchmarks run with no allocations on the hot path where avoidable.
```
/goal Complete Phase 2 of PLAN.md: calendar logic in internal/calendar.
End state:
(1) IsBusinessDay(date, country, region) respects each country's weekend days from the data layer and the observed-day shifts stored in data.
(2) AddBusinessDays(date, n, country, region) is correct for positive N, negative N, and N=0. The rule for N=0 and for negative N is documented in a doc comment and in CLAUDE.md's semantics section only if I approve that edit.
(3) ListHolidays(country, year, region, type) filters correctly by region and type.
(4) All date math uses time.Date in UTC. Tests cover leap days, year boundaries, and weekend definitions that differ by country (for example, Fri-Sat).
(5) Table-driven tests plus property tests (add N then subtract N returns to a business day). internal/calendar has no I/O.
(6) Benchmarks for the three functions are written to bench/phase2.txt with -benchmem output.
Check: `gofmt -l .` prints nothing; `go vet ./...`; `go test ./... -race -count=1`; `go test -bench . -benchmem -run '^$' ./internal/calendar`. Print the output of each.
Constraints: stdlib only, no I/O in internal/calendar, do not change data/ files. Read the existing code and CLAUDE.md first, write a short plan, then implement.
Stop after 40 turns and report results, open questions about business-day rules, and anything left undone.
```
## Phase 3: HTTP API
Goal: the three endpoints, with validation, error shape, caching headers, and an OpenAPI file.
- Routes: `GET /v1/business-day`, `GET /v1/add-business-days`, `GET /v1/holidays`, using net/http 1.22 ServeMux patterns.
- Errors: `{error:{code,message}}`, with the status mapping in SPEC.md (`invalid_date`, `unknown_country`, `unknown_region`, `year_not_available` as 404).
- Caching: `ETag` from a data version hash, plus `Cache-Control`.
- OpenAPI file at `docs/openapi.yaml`.
- No rate limiting yet; that is Phase 4.
- Done when: handler tests pass and the verify skill passes its smoke steps.
```
/goal Complete Phase 3 of PLAN.md: HTTP API for the holidays service.
End state:
(1) Handlers for GET /v1/business-day, GET /v1/add-business-days, and GET /v1/holidays, registered with net/http 1.22 ServeMux patterns.
(2) Input validation for every parameter. Bound n and year ranges. Reject bad input with the error codes and statuses from SPEC.md.
(3) Errors use the shape {error:{code,message}}. Years without data return 404 with code year_not_available.
(4) Every 200 response has an ETag derived from the data version hash and a Cache-Control header. The same request returns the same ETag until the data changes.
(5) docs/openapi.yaml describes all three endpoints and the error shape.
(6) Handler tests use httptest and are table-driven.
Check: run the verify skill through Step 7 and print each step's output. Also `go test ./... -race -count=1` and `go vet ./...`.
Constraints: stdlib only; no rate limiting yet; no API key handling yet; do not commit. Read SPEC.md and CLAUDE.md, write a short plan, then implement.
Stop after 40 turns and report results, what is left, and any questions about the API contract.
```
## Phase 4: Rate limiting and API keys
Goal: token bucket limits per API key and per IP, with tier config and 429 handling.
- Token bucket per API key. Anonymous requests are limited by IP.
- Tier limits come from a config file, never from handlers.
- Keys come from env vars only (`YOUR_API_KEY` as the placeholder).
- Trust `X-Forwarded-For` only when a trusted proxy list is configured.
- 429 responses include `Retry-After` and the standard `RateLimit` headers.
- Tests use a fake clock.
- Done when: race-clean tests pass with the fake clock, and no key appears in logs or responses.
```
/goal Complete Phase 4 of PLAN.md: rate limiting and API keys.
End state:
(1) A token bucket per API key (header X-API-Key) and a token bucket per client IP for anonymous requests.
(2) Tier limits (free, starter, pro) load from a config file. Handlers do not hardcode limits.
(3) X-Forwarded-For and similar headers are used only when a trusted proxy list is configured. Otherwise the connection's remote address is used.
(4) Exceeded limits return 429 with Retry-After and RateLimit headers. Responses use the error shape from Phase 3.
(5) API keys come only from environment variables. Use YOUR_API_KEY in examples. No key, secret, or key prefix appears in logs or responses.
(6) Tests use a fake clock and are table-driven. Cover refill, burst, per-key isolation, per-IP isolation, and trusted vs untrusted forwarded headers.
Check: `gofmt -l .` prints nothing; `go vet ./...`; `go test ./... -race -count=1`. Print the output of each.
Constraints: stdlib only; keep the limiter in internal/ratelimit; do not edit SPEC.md. Read SPEC.md, REVIEW.md, and CLAUDE.md first, write a short plan, then implement.
Stop after 40 turns and report results, what is left, and any questions about tier numbers.
```
## Phase 5: Performance and caching
Goal: measured latency, an in-process LRU, and a documented allocation budget.
- Benchmarks for every endpoint's hot path.
- In-process LRU for computed results, keyed by data version.
- Load test as a stdlib Go program at `cmd/loadtest`, not an external tool.
- Allocation budget in `bench/budget.md`.
- Review cache headers: ETags must change when data changes, and no per-user data appears in cacheable responses.
- Targets from SPEC.md: p99 under 5 ms server-side, cache hit rate above 95 percent at the CDN.
- Done when the bench-compare skill reports no regressions and the load test meets the p99 target locally.
```
/goal Complete Phase 5 of PLAN.md: performance and caching.
End state:
(1) Benchmarks for each endpoint's hot path, saved to bench/phase5.txt with -benchmem.
(2) An in-process LRU caches computed results. Cache keys include the data version. A cache hit allocates nothing beyond the response write, and this is measured.
(3) cmd/loadtest is a stdlib-only Go program that runs a configurable request mix and reports p50, p99, and error counts.
(4) bench/budget.md lists the allocs/op budget for each hot path, with the measured value beside it.
(5) Cache header review: ETag changes when data changes, Vary is set correctly, and no user-specific data appears in cacheable responses. Record findings in bench/cache-review.md.
Check: run the bench-compare skill against bench/baseline.txt and report any regression over 10 percent in ns/op or any allocs/op increase. Run `go test ./... -race -count=1`, `go vet ./...`, and the loadtest against a local server. Print the p50 and p99 numbers.
Constraints: stdlib only; do not update bench/baseline.txt without my approval; do not change calendar semantics. Read the code and CLAUDE.md first, write a short plan, then implement.
Stop after 40 turns and report measured numbers, what is left, and any regressions.
```
## Phase 6: Hardening, deploy, and remaining countries
Goal: production basics, a container build, and a repeatable process for the rest of the 60 countries.
- Multi-stage Dockerfile that runs as a non-root user.
- Graceful shutdown with `signal.NotifyContext` and `http.Server.Shutdown`.
- `/healthz` and `/metrics` counters for requests, cache hits, and 429s.
- Structured logs with `slog`.
- README covering setup, commands, and API use.
- Country workflow: the add-country skill, run once per country with source files you supply.
- Docker commands need your approval in the permission settings.
```
/goal Complete Phase 6 of PLAN.md: hardening and deploy prep.
End state:
(1) Dockerfile with a multi-stage build that runs as a non-root user and exposes the API port.
(2) Graceful shutdown on SIGINT and SIGTERM using signal.NotifyContext and http.Server.Shutdown with a timeout.
(3) /healthz returns 200 when the data loaded. /metrics exposes counters for requests, cache hits, and 429s.
(4) Logs use slog in structured JSON. Logs never include API keys or request secrets.
(5) README.md covers prerequisites, build, run, test, the API examples from SPEC.md, and how to add a country with the add-country skill.
(6) The add-country skill is tested end to end on one country with a source file you supply. Do not fill any missing holidays.
Check: run the verify skill through Step 7; `go run ./cmd/datacheck`; `go test ./... -race -count=1`. If you approve Docker commands, also run `docker build .` and show `curl` against /healthz from the container. Print each output.
Constraints: stdlib only; no secrets in the Dockerfile or the repo; do not commit .env. Read the plan and CLAUDE.md first, write a short plan, then implement.
Stop after 40 turns and report what shipped, what is left, and which countries still need source data.
```
REVIEW.md# Review criteria
Use these when running /code-review. Check the high-risk areas first.
## Date math
- Check year boundaries, leap days, and add-business-days with negative N and N=0.
- Weekend days must come from the country's data, not a global default.
- Region filtering returns holidays for that region and its parents only.
- Civil dates must not shift with time zones. Date-only math uses UTC via time.Date.
## Data changes
- Files under data/ change only when the PR says so.
- cmd/datacheck must pass.
- Flag any holiday date that has no reviewed source or looks invented.
## Hot path
- Flag new allocations in internal/calendar or lookup code unless the PR includes a benchmark note.
- No file, DB, or network reads per request.
- No global locks held under contention.
## Cache
- The ETag must change when the data version changes.
- Responses that can be cached must not contain user-specific data.
## Rate limiting
- Do not trust X-Forwarded-For or similar headers unless a trusted proxy is configured.
- Limits come from config. Handlers must not hardcode them.
## Security
- No secrets in code, logs, or test fixtures.
- Validate all query parameters. Bound n and year ranges.
## Tests
- New behavior needs table-driven tests.
- go test ./... -race must be clean.
## Skip
- Formatting and lint issues that gofmt and golangci-lint already catch.
SPEC.md# Holidays and business days API: product spec
## Goals
- Answer business-day questions for 60 countries with low latency.
- Serve reviewed holiday data embedded in the binary, loaded into memory at startup.
- Support regional holidays, observed-day shifts, and per-country weekend definitions.
- Make responses cacheable at a CDN with strong validators.
- Enforce per-key and per-IP rate limits with clear 429 signals.
## Non-goals
- No web UI.
- No billing UI. Billing integration is an open question.
- No automatic sourcing of holiday data. Every holiday is hand-reviewed and committed.
- No user-specific data in responses.
## Conventions
- Dates are ISO 8601 civil dates (`YYYY-MM-DD`), with no time zone and no time of day.
- Country codes are ISO 3166-1 alpha-2, uppercase (`US`, `DE`, `AE`).
- Region codes are ISO 3166-2 subdivision suffixes (`CA`, `BY`), valid only for that country.
- All errors use the shape `{"error":{"code":"...","message":"..."}}`.
## Endpoints
### GET /v1/business-day
Query: `date` (required), `country` (required), `region` (optional).
```
GET /v1/business-day?date=2026-12-25&country=US®ion=CA
```
```json
{
"date": "2026-12-25",
"country": "US",
"region": "CA",
"weekday": "friday",
"is_business_day": false,
"holiday": {"name": "Christmas Day", "type": "public"}
}
```
When the date is not a holiday, `holiday` is `null`.
### GET /v1/add-business-days
Query: `date` (required), `n` (required, integer), `country` (required), `region` (optional).
```
GET /v1/add-business-days?date=2026-03-02&n=5&country=DE
```
```json
{
"from": "2026-03-02",
"n": 5,
"country": "DE",
"region": null,
"result": "2026-03-09"
}
```
Rules, proposed for review:
- `n = 0` returns `from` unchanged, even if it is not a business day.
- Positive `n` counts business days after `from`. `from` itself is not counted.
- Negative `n` counts business days backward. The result is the nth business day before `from`.
- `|n|` must be at most 3650. Larger values return `invalid_parameter`.
- Results must stay inside loaded years. Crossing into an unloaded year returns `year_not_available`.
### GET /v1/holidays
Query: `country` (required), `year` (required), `region` (optional), `type` (optional: `public`, `bank`, `observance`).
```
GET /v1/holidays?country=US&year=2026®ion=CA&type=public
```
```json
{
"country": "US",
"year": 2026,
"region": "CA",
"holidays": [
{"date": "2026-01-01", "name": "New Year's Day", "local_name": "New Year's Day", "type": "public", "regions": []}
]
}
```
Results are sorted by date. A year with no data returns `year_not_available`, never an empty list.
## Error codes
| Code | HTTP status | Meaning |
|---|---|---|
| `invalid_date` | 400 | Date is not a valid `YYYY-MM-DD` civil date |
| `invalid_parameter` | 400 | Missing or malformed parameter, including `n` out of range |
| `invalid_api_key` | 401 | `X-API-Key` header present but not recognized |
| `unknown_country` | 404 | Country code has no data |
| `unknown_region` | 404 | Region code is not valid for the country |
| `year_not_available` | 404 | No data loaded for the requested year |
| `rate_limited` | 429 | Limit exceeded; response includes `Retry-After` |
## Data file format
One file per country per year: `data/<cc>/<year>.json`.
```json
{
"country": "US",
"year": 2026,
"weekend": ["sat", "sun"],
"holidays": [
{
"date": "2026-01-01",
"name": "New Year's Day",
"local_name": "New Year's Day",
"type": "public",
"regions": [],
"observed_for": null
}
],
"reviewed_by": "NAME_OR_INITIALS",
"reviewed_at": "2026-01-15"
}
```
Field rules:
- `weekend` lists lowercase weekday names. Some countries use `["fri", "sat"]`.
- `holidays` is sorted by `date`, with no duplicate `(date, name, regions)` entries.
- `regions` is empty for nationwide holidays, or lists valid region codes.
- `observed_for` is set only when a holiday is observed on a different date. It stores the original date. Observed shifts are stored explicitly and never computed.
- `reviewed_by` and `reviewed_at` are required for every file. Placeholder entries use `"TODO_REVIEW"` in `reviewed_by` and must not ship.
## Tiers
Limits are placeholders until pricing is decided.
| Tier | Requests per minute | Monthly quota | Auth |
|---|---|---|---|
| Anonymous (free, by IP) | 30 | 5,000 | None |
| Free key | 60 | 20,000 | `X-API-Key` |
| Starter | 300 | 500,000 | `X-API-Key` |
| Pro | 1,200 | 5,000,000 | `X-API-Key` |
Tier values live in config, never in handlers.
## Latency targets
- p99 under 5 ms server side, for cached responses.
- p99 under 5 ms server side, for uncached computation.
- CDN cache hit rate above 95 percent for the public endpoints.
## Cache design
- `ETag` is a hash of the data version plus the canonical request path and query. The data version changes whenever any data file changes.
- `Cache-Control: public, max-age=3600` for `/v1/business-day` and `/v1/add-business-days`.
- `Cache-Control: public, max-age=86400` for `/v1/holidays`.
- `Vary: Accept-Encoding`. Responses are not keyed on the API key because they contain no user data.
- `RateLimit-*` and `Retry-After` headers are per request and are never stored in cached responses.
- An in-process LRU caches computed results, keyed by the canonical request.
- `If-None-Match` returns `304 Not Modified` when the ETag matches.
## Auth
- Keys are sent in the `X-API-Key` header only. Never in query strings.
- Requests without a key are anonymous and rate limited by client IP.
- Trust for `X-Forwarded-For` is granted only when a trusted proxy list is configured.
## Observability
- Structured logs with `log/slog`, JSON handler, one line per request with method, path, status, latency, and tier. Keys are never logged.
- `GET /healthz` returns `200` once data is loaded.
- `GET /metrics` exposes Prometheus text counters: requests by status, cache hits and misses, 429 responses.
## Open questions
1. Billing provider: which service handles subscriptions and usage, and how are tier changes applied?
2. Key storage: where are hashed API keys kept, and what is the rotation policy?
3. Deployment target: which platform runs the container, and which CDN sits in front?
4. Data sourcing: who supplies and reviews the holiday data for each new country?
5. Confirm the `n` bounds (3650) and the `n = 0` rule.