Wucher Backend
Wucher is a Go backend built with GoFiber. The runtime uses MySQL for persistence and transient auth state, Amazon SQS for asynchronous email jobs, and Amazon SES v2 for email delivery.
Prerequisites
- Go 1.25+
- MySQL
- LocalStack or AWS SQS/SES access for queue testing
Project Structure
cmd/api/API bootstrapcmd/worker/SQS worker bootstrapinternal/app/application wiringinternal/resilience/circuit breaker executor and dependency error classifiersinternal/service/business logicinternal/transport/http/GoFiber handlers and middlewareinternal/repository/mysql/MySQL repositories and transient storesinternal/repository/sqs/SQS publisher/consumer adapterinternal/queue/queue contracts, serializer, retry policy, and worker loop
Runtime Architecture
- HTTP handlers stay thin and delegate to services.
- Auth and other business writes persist to MySQL first.
- API/service code never sends email synchronously.
- Email jobs are written to
email_outbox_messageswhen outbox is enabled, or published directly to SQS when outbox is disabled. - Worker publishes outbox rows to the SQS send queue, consumes SQS messages with long polling, and sends email through Amazon SES v2.
- SES bounce, complaint, and delivery tracking should use a separate flow:
- SES Configuration Set
- SNS topic subscription
- dedicated SQS event queue
- Circuit breakers are applied only at outbound dependency boundaries:
- Microsoft Entra token exchange
- SES delivery
- SQS publish/consume/ack calls
- Transient auth state previously kept in Redis is now stored in MySQL tables:
auth_runtime_tokensqueue_idempotency_records
gorm auto-migration creates these tables on startup.
Getting Started
Install dependencies:
go mod tidy
Create local config from the example:
cp .env.example .env
Run the API:
go run ./cmd/api
Run the worker:
go run ./cmd/worker
Worker monitor endpoints:
curl http://127.0.0.1:9090/health
curl http://127.0.0.1:9090/metrics
curl http://127.0.0.1:9091/health
curl http://127.0.0.1:9091/metrics
/metricsexposes Prometheus format for API and worker processes./healthremains JSON health/worker snapshot.
Environment Variables
Example values are in .env for local development.
Important groups:
- Email delivery
EMAIL_PROVIDEREMAIL_MOCK_DIR
- MySQL
MYSQL_DSNMYSQL_MAX_OPEN_CONNSMYSQL_MAX_IDLE_CONNSMYSQL_CONN_MAX_LIFETIMEMYSQL_CONN_MAX_IDLE_TIME
- Auth
AUTH_EMAIL_VERIFY_URLAUTH_PASSWORD_RESET_URLAUTH_SECURITY_PIN_RESET_URLAUTH_TOTP_CHALLENGE_TTLAUTH_SSO_STATE_TTLAUTH_JWT_ACCESS_SECRETAUTH_JWT_REFRESH_SECRETAUTH_WEBAUTHN_*
- Queue / worker
QUEUE_MESSAGE_SCHEMA_VERSIONQUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACKQUEUE_IDEMPOTENCY_KEY_PREFIXQUEUE_IDEMPOTENCY_TTLQUEUE_PROCESSING_TTLQUEUE_WORKER_CONCURRENCYQUEUE_WORKER_MAX_BATCH_SIZEQUEUE_WORKER_PROCESS_TIMEOUTQUEUE_WORKER_BACKOFF_MINQUEUE_WORKER_BACKOFF_MAXQUEUE_WORKER_PERMANENT_FAILURE_DELAYQUEUE_OUTBOX_ENABLEDQUEUE_OUTBOX_POLL_INTERVALQUEUE_OUTBOX_BATCH_SIZEQUEUE_OUTBOX_DISPATCH_TIMEOUTQUEUE_OUTBOX_LOCK_TTLQUEUE_OUTBOX_MAX_ATTEMPTS
- File queue / file worker
FILE_QUEUE_MESSAGE_SCHEMA_VERSIONFILE_QUEUE_IDEMPOTENCY_KEY_PREFIXFILE_QUEUE_IDEMPOTENCY_TTLFILE_QUEUE_PROCESSING_TTLFILE_QUEUE_WORKER_CONCURRENCYFILE_QUEUE_WORKER_MAX_BATCH_SIZEFILE_QUEUE_WORKER_PROCESS_TIMEOUTFILE_QUEUE_WORKER_SHUTDOWN_TIMEOUTFILE_QUEUE_WORKER_BACKOFF_MINFILE_QUEUE_WORKER_BACKOFF_MAXFILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAYFILE_QUEUE_WORKER_MONITOR_ADDRFILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLDFILE_QUEUE_WORKER_DEPTH_POLL_INTERVALFILE_QUEUE_OUTBOX_ENABLEDFILE_QUEUE_OUTBOX_POLL_INTERVALFILE_QUEUE_OUTBOX_BATCH_SIZEFILE_QUEUE_OUTBOX_DISPATCH_TIMEOUTFILE_QUEUE_OUTBOX_LOCK_TTLFILE_QUEUE_OUTBOX_MAX_ATTEMPTS
- Circuit breakers
CB_DEFAULT_ENABLEDCB_DEFAULT_MAX_REQUESTSCB_DEFAULT_INTERVALCB_DEFAULT_BUCKET_PERIODCB_DEFAULT_TIMEOUTCB_DEFAULT_MIN_REQUESTSCB_DEFAULT_FAILURE_RATIOCB_DEFAULT_CONSECUTIVE_FAILURESCB_MICROSOFT_SSO_ENABLEDCB_SES_ENABLEDCB_SQS_ENABLED
- AWS / SES
AWS_REGIONSES_REGIONSES_FROMSES_CONFIGURATION_SETSES_SEND_TIMEOUTSES_ENDPOINTSES_ALLOW_INSECURE_ENDPOINT
- AWS / SQS
- Email queue:
AWS_REGIONAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYSQS_ENDPOINTSQS_ALLOW_INSECURE_ENDPOINTSQS_QUEUE_URLSQS_QUEUE_TYPESQS_MESSAGE_GROUP_IDSQS_MAX_MESSAGESSQS_WAIT_TIME_SECONDSSQS_VISIBILITY_TIMEOUT- File queue:
FILE_SQS_REGIONFILE_SQS_ENDPOINTFILE_SQS_QUEUE_URLFILE_SQS_QUEUE_TYPEFILE_SQS_MESSAGE_GROUP_IDFILE_SQS_MAX_MESSAGESFILE_SQS_WAIT_TIME_SECONDSFILE_SQS_VISIBILITY_TIMEOUTFILE_SQS_ALLOW_INSECURE_ENDPOINT
- File storage (S3)
FILE_STORAGE_PROVIDERFILE_S3_BUCKETFILE_S3_REGIONFILE_S3_ENDPOINTFILE_S3_FORCE_PATH_STYLEFILE_S3_ALLOW_INSECURE_ENDPOINTFILE_S3_AUTO_CREATE_BUCKETFILE_OBJECT_KEY_PREFIXFILE_S3_PRESIGN_PUT_TTLFILE_S3_PRESIGN_GET_TTL
- WOPI / Collabora
WOPI_TOKEN_SECRETWOPI_PROOF_SECRETWOPI_DISCOVERY_URLWOPI_PUBLIC_BASE_URLWOPI_EDIT_ACTION_URLWOPI_TOKEN_TTLWOPI_MAX_BODY_BYTESWOPI_REQUIRE_PROOF
- Worker mode selector
WORKER_JOB=emailWORKER_JOB=file_processing
WOPI / Collabora Hardening
- Keep WOPI endpoints behind the dedicated
/wopirouter and do not expose them through the session-authenticated app middleware chain. - Use HTTPS end to end in production.
- Restrict network access to the Collabora host or private network allowlist.
- Set Collabora
aliasgroup/host configuration so it can only call back to this backend. - Keep
WOPI_TOKEN_SECRETandWOPI_PROOF_SECRETin env or a secrets manager and rotate them periodically. - Scope IAM to the minimum S3 bucket/prefix needed by file storage.
- Keep direct S3 URLs disabled for editor access; the backend should proxy file bytes.
- Ensure backend logs capture open/save/exit-save events for auditability.
- Verify
/hosting/discoveryreachability and S3 connectivity in health checks before enabling Collabora traffic.
Per-dependency overrides are supported with the same shape as the default block:
CB_MICROSOFT_SSO_*CB_SES_*CB_SQS_*
Example: CB_SES_TIMEOUT=45s or CB_SQS_CONSECUTIVE_FAILURES=3.
Circuit Breakers
- Breakers are scoped per outbound dependency, not per HTTP endpoint and not global middleware.
- MySQL repositories and internal business logic are intentionally not wrapped.
- Only dependency-health failures count toward opening the breaker.
- Microsoft SSO: network errors, timeouts, HTTP
429, and5xx - SES: network errors, throttling, and AWS server faults
- SQS: network errors, throttling, and AWS server faults
- Microsoft SSO: network errors, timeouts, HTTP
- Client/config/input errors are excluded so they do not open the breaker.
- State transitions are logged through the standard application logger.
Queue Behavior
- Long polling is enabled through
SQS_WAIT_TIME_SECONDS. - Messages are deleted only after successful processing or explicit ack of duplicates.
- Transient failures use
ChangeMessageVisibilitywith backoff. - Permanent failures are left unacked so SQS redrive policy can move them to the DLQ.
- The default producer schema is
v2; legacy payload fallback remains available for older messages. - Configure DLQ on the queue itself in AWS/LocalStack. The application no longer publishes directly to a DLQ URL.
Prometheus Metrics
Scrape endpoints:
- API:
http://<api-host>:8080/metrics - Email worker monitor:
http://<worker-host>:9090/metrics - File worker monitor:
http://<worker-host>:9091/metrics
Example Prometheus scrape config:
scrape_configs:
- job_name: "wucher-api"
static_configs:
- targets: ["127.0.0.1:8080"]
- job_name: "wucher-email-worker"
static_configs:
- targets: ["127.0.0.1:9090"]
- job_name: "wucher-file-worker"
static_configs:
- targets: ["127.0.0.1:9091"]
Metrics and labels:
app_aws_requests_total{component,service,operation,result}app_aws_request_duration_seconds{component,service,operation}app_aws_errors_total{component,service,operation,error_class}whereerror_classis4xx,5xx, orotherapp_aws_transferred_bytes_total{component,service,operation,direction}app_queue_messages_received_total{component,queue_name}app_queue_messages_deleted_total{component,queue_name}app_queue_visibility_changes_total{component,queue_name,result}app_queue_send_total{component,queue_name,result}app_queue_retries_total{component,queue_name,result}app_queue_message_bytes_total{component,queue_name,direction}app_queue_depth{component,queue_name}app_worker_jobs_processed_total{component,job_type,result}app_worker_job_duration_seconds{component,job_type}app_email_send_total{component,provider,result}app_email_send_duration_seconds{component,provider}app_file_upload_total{component,result}app_file_delete_total{component,result}app_file_head_total{component,result}app_file_presign_download_total{component,result}
PromQL examples:
- Error rate per AWS operation:
sum by (component, service, operation) (rate(app_aws_requests_total{result="error"}[5m]))
/
clamp_min(sum by (component, service, operation) (rate(app_aws_requests_total[5m])), 1e-9)
- P95 latency per AWS operation:
histogram_quantile(
0.95,
sum by (le, component, service, operation) (
rate(app_aws_request_duration_seconds_bucket[5m])
)
)
- Queue depth:
max by (component, queue_name) (app_queue_depth)
- Worker success/failure count:
sum by (component, job_type, result) (increase(app_worker_jobs_processed_total[15m]))
- SES send failure rate:
sum by (component) (rate(app_email_send_total{provider="ses", result="error"}[5m]))
/
clamp_min(sum by (component) (rate(app_email_send_total{provider="ses"}[5m])), 1e-9)
- S3 4xx/5xx error rate:
sum by (operation, error_class) (rate(app_aws_errors_total{service="s3"}[5m]))
- S3 GetObject transferred bytes (estimated from presign download flow):
sum by (component) (rate(app_aws_transferred_bytes_total{service="s3",operation="getobject",direction="download"}[5m]))
- SQS message bytes in/out:
sum by (component, queue_name, direction) (rate(app_queue_message_bytes_total[5m]))
- SQS retry/requeue count:
sum by (component, queue_name, result) (increase(app_queue_retries_total[15m]))
File Manager
- Queue separation:
- Email queue:
app-queue(+app-queue-dlq) - File queue:
app-file-queue(+app-file-queue-dlq)
- Email queue:
FILE_SQS_QUEUE_URLmust be different fromSQS_QUEUE_URL.- API startup validates queue separation and refuses startup on misconfiguration.
- Upload writes file metadata + file outbox durably; file queue publish is handled asynchronously by the file outbox dispatcher.
Local run checklist:
# 1) Start localstack (queue + bucket bootstrap script will run)
cd ../localstack && docker compose up -d
WORKER_JOB=file_processing go run ./cmd/worker
SES Delivery Notes
EMAIL_PROVIDER=mockis the recommended local/dev default when you do not have real AWS SES access.- Mock delivery writes one JSON file per delivered email into
EMAIL_MOCK_DIR. SES_FROMmust be a verified identity or domain in Amazon SES.SES_ENDPOINTshould be blank in production and only overridden for local/dev stacks such as LocalStack.- Do not point SES events to the main send queue. Use
SES_CONFIGURATION_SETwith SNS -> separate SQS event queue. - The codebase includes an SES event parser for SNS-wrapped notifications, but an event consumer is not started by default.
Migration Notes
Detailed migration notes are in docs/queue-sqs-migration.md. Data migration tracking wiki is in docs/data-migration-wiki.md.
Main breaking changes:
- Redis runtime support has been removed from the codebase.
- Redis env vars are obsolete and should be removed from deployment config.
- SMTP runtime env/config has been removed in favor of SES.
- Worker now relies on MySQL for queue idempotency and auth/runtime state persistence.
- SQS must have a redrive policy and DLQ configured outside the application.
CI / Build Selection
The pipeline always builds and pushes the api image on every push to development or staging.
To also build and push the worker or file-worker image, include the corresponding tag anywhere in the commit message:
| Tag | Image built | Docker target |
|---|---|---|
#PUSH-WORKER |
mybitlab/wfm:<tag>-worker |
worker |
#PUSH-FILE-WORKER |
mybitlab/wfm:<tag>-file-worker |
file-worker |
Both tags can appear in the same commit message to build all three in one pipeline run:
ci: deploy all services #PUSH-WORKER #PUSH-FILE-WORKER
Omitting a tag skips that image entirely — it will not be built or pushed.
Testing
Run the full test suite:
go test ./...
Generate coverage:
go test ./... -coverprofile=coverage.out
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html