Error Handling
Overview
Errors can occur at two points in the system: during upstream sync and during client requests. Each is handled differently.
Upstream Sync Errors
Upstream API Failures
If any of the three upstream API calls fail (non-2xx status code), HttpRequestException is thrown and the entire sync aborts.
What happens:
- No version increment occurs.
- No data is written to Postgres.
- The exception propagates to ASP.NET Core's default exception handler, which returns
500 Internal Server Error. In Development mode, the response includes a developer exception page (HTML). In Production mode, the response body is empty. - Existing data in Postgres remains untouched — clients continue to receive the last successfully synced data.
Recovery: The CronJob runs with backoffLimit: 0, so a failed job is not retried — it is marked as failed and can be investigated via kubectl get jobs. The next scheduled run (03:00) recovers the data since the sync is idempotent. For immediate recovery, trigger a sync manually via POST /api/internal/sync or by running the image with --sync.
Partial Sync Failures
If upstream fetches succeed but a Postgres write fails mid-sync:
- The version counter has already been incremented.
- Some raw FHIR or output records may have been upserted with the new version number.
- Soft-deletes may not have been applied.
- The
SyncHistoryEntryfor this run will not have been written, so the run is missing fromSyncHistory.
Impact: Clients using ?since-version=N may see some but not all changes from this sync run.
Recovery: Trigger a new sync. The next sync is idempotent — content-hash change detection means already-correct records are left alone, while inconsistent records are reconciled. No manual intervention is needed.
Timeout
The sync (HTTP endpoint or --sync) has no explicit timeout configured. The CronJob should set an activeDeadlineSeconds to prevent runaway syncs:
jobTemplate:
spec:
activeDeadlineSeconds: 300 # Kill after 5 minutes
Client Request Errors
Authentication Errors
| Scenario | Response |
|---|---|
Missing X-API-KEY header |
401 Unauthorized with { "error": "Invalid or missing API key." } |
| Invalid API key | 401 Unauthorized with { "error": "Invalid or missing API key." } |
The error message is intentionally identical for both cases to avoid leaking information about valid keys.
Rate Limiting
| Scenario | Response |
|---|---|
| More than 120 requests/minute from the same API key | 429 Too Many Requests with { "error": "Rate limit exceeded. Try again later." } and a Retry-After header |
More than 120 requests/minute without a valid API key (shared anonymous bucket) |
429 Too Many Requests with the same body |
Limits are enforced per API key per replica using an in-memory sliding window (RateLimiting config section, overridable via RateLimiting__* env vars; RateLimiting__Enabled=false disables limiting entirely). Requests without a valid API key share a single anonymous bucket, so the number of limiter partitions stays bounded. The health and Swagger endpoints are not rate limited. Rejections are logged at Warning level with the source IP only — API keys are never logged.
Invalid since-version
| Scenario | Response |
|---|---|
?since-version=abc (non-numeric) |
400 Bad Request (ASP.NET model binding error) |
?since-version=-1 (negative) |
200 OK with all versioned items (treated as "everything since before the beginning") |
?since-version=999999 (future) |
200 OK with empty items list and current version |
Postgres Connection Failures
If Postgres is unreachable when a client makes a request:
- The controller throws an unhandled
NpgsqlException. - ASP.NET Core returns
500 Internal Server Error. - The error is logged at
Errorlevel.
Monitoring: Watch for 500 responses on the output endpoints. Postgres connection issues typically resolve once the database is reachable again — no data loss occurs.
Logging
All log output goes to stdout (Serilog console). In test/prod each event is a single compact JSON object (one line), so fields are directly searchable in Splunk — NHN k8s ships pod stdout to Splunk via Fluent Bit (default index kube_MILJØ, e.g. kube_prod). In Development the console uses a readable text template instead.
Every event is enriched with Service (nompd-api), Version (the APP_VERSION environment variable, falling back to the assembly version), MachineName, ThreadId, and — for request-scoped events — RequestId (the correlation ID).
| Level | What |
|---|---|
Information |
Startup info, request log lines, upstream fetches, sync lifecycle (started, completed) |
Warning |
4xx responses, rate limit rejections, failed API key auth |
Error |
5xx responses, unhandled exceptions (with correlation ID), upstream fetch failures, health-check failures |
Request Logging
- Every non-health request is logged with method, path, status code, and duration.
/api/internal/healthis excluded (Kubernetes probes). - v1 requests are logged as
{Method} {Path} {StatusCode} in {Elapsed}ms. - v2 requests additionally include the query string, the client IP, and the consumer — the
client_idclaim on the HelseID access token. The query string (e.g.since-version=N) reveals how consumers poll for updated data.
Correlation
- Every response includes an
X-Correlation-Idheader. Clients may send their ownX-Correlation-Idrequest header; it is reused, otherwise a new ID is generated. - The ID is available as the
RequestIdproperty on all log events for that request, and in thecorrelationIdfield of500error bodies.
Key Log Messages
# Startup (Program)
"Starting nompd-api: Environment={Environment}, Version={Version}, UpstreamBaseUrl={UpstreamBaseUrl}, Postgres={Postgres}"
# Sync lifecycle (SyncService)
"Starting sync"
"Sync completed in {Duration}ms: {Added} added, {Updated} updated, {Unchanged} unchanged, {Deleted} deleted (version {Version})"
# Upstream fetch (UpstreamApiClient)
"Fetched {Count} items from {Path}"
For per-collection detail (including the business keys of added/updated/deleted records), query the SyncHistory collection — every sync run writes one entry there.
Structured Logging
All log messages use structured logging with named parameters, making them field-searchable in Splunk (e.g. Properties.consumer:"my-client" or Properties.StatusCode:500).
Monitoring Recommendations
| What to monitor | Alert condition |
|---|---|
| Sync CronJob status | Job failed (no retries; backoffLimit: 0) |
| Sync duration | Duration > 60 seconds (indicates upstream slowness) |
| Sync result counts | deleted count unusually high (possible upstream issue) |
| API 401 responses | Spike in unauthorized requests (possible key leak or misconfiguration) |
| API 429 responses | Sustained 429s (a client is hitting the rate limit; consider raising the limit or throttling the client) |
| API 500 responses | Any 500 response (Postgres or unexpected error) |
| Data freshness | SyncMetadata.LastSyncAt older than 36 hours |
| Sync history gap | Most recent SyncHistory entry's SyncedAt older than 36 hours, or Version lags behind SyncMetadata.CurrentVersion (indicates partial sync failure) |
Failure Modes Summary
| Failure | Data impact | Auto-recovery |
|---|---|---|
| Upstream API down | None (old data served) | Yes, next sync |
| Upstream returns empty data | All records soft-deleted | Yes, next sync restores if data returns |
| Postgres down during sync | Partial writes possible | Yes, next sync overwrites |
| Postgres down during client request | 500 error, no data | Yes, when Postgres recovers |
| Invalid upstream data (missing IDs) | Items silently skipped | No — check upstream data quality |