Job Queues
When processing invoices at scale, synchronous API calls aren't always practical. This guide covers best practices for implementing job queues and async processing patterns with GoRoute.
Why Use Job Queues?β
| Challenge | Solution |
|---|---|
| Rate limits on API calls | Queue and throttle requests |
| Network timeouts | Retry failed jobs automatically |
| Batch processing thousands of invoices | Process in parallel with workers |
| System outages | Persist jobs and resume later |
| Audit trail | Track every job's status |
Architecture Patternβ
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Your App ββββββΆβ Job Queue ββββββΆβ Workers β
β (Producer) β β (Redis/SQS) β β (Consumers) β
βββββββββββββββ βββββββββββββββ ββββββββ¬βββββββ
β
βΌ
ββββββββββ βββββ
β GoRoute API β
βββββββββββββββ
Implementation Examplesβ
Python with Celeryβ
# tasks.py
from celery import Celery
from goroute import GoRouteClient
import os
app = Celery('invoice_tasks', broker=os.environ['REDIS_URL'])
client = GoRouteClient(api_key=os.environ['GOROUTE_API_KEY'])
@app.task(
bind=True,
max_retries=5,
default_retry_delay=60,
autoretry_for=(Exception,),
retry_backoff=True
)
def send_invoice(self, invoice_xml: str, metadata: dict):
"""Send an invoice via GoRoute with automatic retries."""
try:
result = client.documents.send(
document=invoice_xml,
metadata=metadata
)
# Store result for tracking
store_result(metadata['invoice_id'], result)
return {
'status': 'success',
'transaction_id': result.transaction_id
}
except client.RateLimitError as e:
# Respect rate limits - retry after suggested delay
raise self.retry(countdown=e.retry_after)
except client.ValidationError as e:
# Don't retry validation errors - they won't succeed
return {
'status': 'failed',
'error': str(e),
'retryable': False
}
# Producer code
def queue_invoices(invoices: list):
"""Queue multiple invoices for async processing."""
for invoice in invoices:
send_invoice.delay(
invoice_xml=invoice.to_xml(),
metadata={
'invoice_id': invoice.id,
'customer_id': invoice.customer_id
}
)
Node.js with BullMQβ
// queue.ts
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis(process.env.REDIS_URL);
const GOROUTE_DOCUMENTS_URL =
'https://app.goroute.ai/peppol-api/api/v1/documents';
// Create the queue
export const invoiceQueue = new Queue('invoices', { connection });
// Create the worker
const worker = new Worker('invoices', async (job) => {
const { invoiceXml, receiverScheme, receiverId, metadata } = job.data;
const headers = new Headers();
headers.set('X-API-Key', process.env.GOROUTE_API_KEY);
headers.set('Content-Type', 'application/json');
// Stable per invoice, so a retry of this job cannot double-send
headers.set('Idempotency-Key', 'invoice-' + metadata.invoiceId);
const body = JSON.stringify({
receiver_scheme: receiverScheme,
receiver_id: receiverId,
document: invoiceXml,
metadata,
});
const init: RequestInit = { headers, body };
init.method = 'POST';
const response = await fetch(GOROUTE_DOCUMENTS_URL, init);
// 202 Accepted - queued for delivery
if (response.status === 202) {
const result = await response.json();
return { status: 'success', transactionId: result.transaction_id };
}
if (response.status === 429) {
// Rate limited - throw so BullMQ retries with backoff
throw new Error('Rate limited, retry-after: ' + response.headers.get('Retry-After'));
}
if (response.status === 400) {
// Validation error - permanent, do not retry
const detail = await response.text();
return { status: 'failed', error: detail, retryable: false };
}
// Anything else (5xx, network) - throw so BullMQ retries
throw new Error('Send failed: ' + response.status);
}, {
connection,
concurrency: 10, // Process 10 jobs in parallel
limiter: {
max: 100, // Max 100 jobs
duration: 60000 // Per minute
}
});
// Handle events
worker.on('completed', (job, result) => {
console.log('Invoice sent:', job.data.metadata.invoiceId, result);
});
worker.on('failed', (job, error) => {
console.error('Invoice failed:', job.data.metadata.invoiceId, error);
});
C# with Hangfireβ
// InvoiceJobs.cs
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Hangfire;
public class InvoiceJobs
{
private readonly HttpClient _http;
private readonly ILogger<InvoiceJobs> _logger;
// Registered via IHttpClientFactory with BaseAddress
// https://app.goroute.ai/peppol-api/ and the X-API-Key default header.
public InvoiceJobs(HttpClient http, ILogger<InvoiceJobs> logger)
{
_http = http;
_logger = logger;
}
[AutomaticRetry(Attempts = 5, DelaysInSeconds = new[] { 60, 300, 900, 3600, 7200 })]
public async Task<SendResult> SendInvoiceAsync(string invoiceXml, InvoiceMetadata metadata)
{
var payload = new Dictionary<string, object>();
payload["receiver_scheme"] = metadata.ReceiverScheme;
payload["receiver_id"] = metadata.ReceiverId;
payload["document"] = invoiceXml;
using var request = new HttpRequestMessage(
HttpMethod.Post, "api/v1/documents");
request.Content = JsonContent.Create(payload);
// Stable per invoice, so a Hangfire retry cannot double-send
request.Headers.Add("Idempotency-Key", "invoice-" + metadata.InvoiceId);
var response = await _http.SendAsync(request);
// 202 Accepted - queued for delivery
if (response.StatusCode == HttpStatusCode.Accepted)
{
using var doc = JsonDocument.Parse(
await response.Content.ReadAsStringAsync());
var transactionId = doc.RootElement
.GetProperty("transaction_id").GetString();
_logger.LogInformation(
"Invoice {InvoiceId} sent successfully. Transaction: {TransactionId}",
metadata.InvoiceId, transactionId);
return new SendResult { Success = true, TransactionId = transactionId };
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limited. Retry after {Seconds}s", response.Headers.RetryAfter?.Delta?.TotalSeconds);
throw new HttpRequestException("Rate limited"); // Retried by Hangfire
}
var error = await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.BadRequest)
{
_logger.LogError("Validation failed for {InvoiceId}: {Error}",
metadata.InvoiceId, error);
// Return failure without throwing - don't retry
return new SendResult { Success = false, Error = error };
}
// Anything else (5xx, network) - throw so Hangfire retries
throw new HttpRequestException("Send failed: " + (int)response.StatusCode);
}
}
// Usage
public class InvoiceService
{
public void QueueInvoice(Invoice invoice)
{
BackgroundJob.Enqueue<InvoiceJobs>(
x => x.SendInvoiceAsync(invoice.ToXml(), invoice.Metadata));
}
public void QueueBatch(IEnumerable<Invoice> invoices)
{
foreach (var invoice in invoices)
{
BackgroundJob.Enqueue<InvoiceJobs>(
x => x.SendInvoiceAsync(invoice.ToXml(), invoice.Metadata));
}
}
}
Queue Design Patternsβ
Priority Queuesβ
Process urgent invoices first:
# High priority for due-date invoices
@app.task(queue='high_priority')
def send_urgent_invoice(invoice_xml, metadata):
return send_invoice(invoice_xml, metadata)
@app.task(queue='default')
def send_normal_invoice(invoice_xml, metadata):
return send_invoice(invoice_xml, metadata)
# Queue based on urgency
def queue_invoice(invoice):
if invoice.days_until_due < 3:
send_urgent_invoice.delay(invoice.xml, invoice.metadata)
else:
send_normal_invoice.delay(invoice.xml, invoice.metadata)
Dead Letter Queuesβ
Handle permanently failed jobs:
// Configure DLQ for failed jobs
const invoiceQueue = new Queue('invoices', {
connection,
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 60000
},
removeOnComplete: 100,
removeOnFail: false // Keep failed jobs for analysis
}
});
// Process DLQ periodically
async function processDLQ() {
const failedJobs = await invoiceQueue.getFailed(0, 100);
for (const job of failedJobs) {
// Notify customer service
await notifyFailure(job.data.metadata);
// Move to manual review queue
await manualReviewQueue.add('review', {
originalJob: job.data,
error: job.failedReason,
attempts: job.attemptsMade
});
// Remove from failed queue
await job.remove();
}
}
Batch Processingβ
Group invoices for efficient processing:
from celery import group
def send_invoice_batch(invoices: list, batch_size: int = 50):
"""Send invoices in batches with controlled parallelism."""
# Create groups of tasks
for i in range(0, len(invoices), batch_size):
batch = invoices[i:i + batch_size]
# Create a group of parallel tasks
job_group = group(
send_invoice.s(inv.xml, inv.metadata)
for inv in batch
)
# Execute batch and wait for results
result = job_group.apply_async()
# Wait for batch to complete before next batch
# This prevents overwhelming the API
results = result.get(timeout=300)
# Log batch results
success = sum(1 for r in results if r['status'] == 'success')
logger.info(f"Batch {i//batch_size + 1}: {success}/{len(batch)} successful")
Rate Limitingβ
Respecting API Limitsβ
GoRoute has rate limits to ensure fair usage:
| Tier | Requests/min | Concurrent |
|---|---|---|
| Standard | 100 | 10 |
| Professional | 500 | 50 |
| Enterprise | Custom | Custom |
Implementing Rate Limitingβ
from ratelimit import limits, sleep_and_retry
# Limit to 100 calls per minute
@sleep_and_retry
@limits(calls=100, period=60)
def rate_limited_send(invoice_xml, metadata):
return client.documents.send(invoice_xml, metadata)
@app.task
def send_invoice(invoice_xml, metadata):
return rate_limited_send(invoice_xml, metadata)
Token Bucket Patternβ
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
reservoir: 100, // Initial tokens
reservoirRefreshAmount: 100,
reservoirRefreshInterval: 60 * 1000, // Refill every minute
maxConcurrent: 10,
minTime: 100 // Min 100ms between requests
});
async function sendWithRateLimit(invoice: Invoice) {
return limiter.schedule(() =>
client.documents.send(invoice)
);
}
Monitoringβ
Job Metricsβ
Track queue health:
from prometheus_client import Counter, Histogram, Gauge
jobs_total = Counter('invoice_jobs_total', 'Total jobs processed', ['status'])
job_duration = Histogram('invoice_job_duration_seconds', 'Job processing time')
queue_depth = Gauge('invoice_queue_depth', 'Current queue depth')
@app.task
def send_invoice(invoice_xml, metadata):
with job_duration.time():
try:
result = client.documents.send(invoice_xml, metadata)
jobs_total.labels(status='success').inc()
return result
except Exception as e:
jobs_total.labels(status='error').inc()
raise
Alertingβ
Set up alerts for queue issues:
# prometheus-alerts.yaml
groups:
- name: invoice_queue
rules:
- alert: HighQueueDepth
expr: invoice_queue_depth > 1000
for: 5m
labels:
severity: warning
annotations:
summary: "Invoice queue depth is high"
- alert: HighFailureRate
expr: rate(invoice_jobs_total{status="error"}[5m]) > 0.1
for: 5m
labels:
severity: critical
annotations:
summary: "High invoice job failure rate"
Best Practicesβ
1. Idempotencyβ
Ensure jobs can be safely retried:
@app.task
def send_invoice(invoice_xml, metadata):
# Check if already sent (idempotency)
existing = get_transaction(metadata['invoice_id'])
if existing:
return {'status': 'already_sent', 'transaction_id': existing.id}
result = client.documents.send(invoice_xml, metadata)
# Store result atomically
store_transaction(metadata['invoice_id'], result)
return result
2. Graceful Shutdownβ
Handle shutdown without losing jobs:
import signal
def shutdown_handler(signum, frame):
logger.info("Shutting down gracefully...")
worker.shutdown() # Stop accepting new jobs
worker.wait() # Wait for current jobs to finish
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)
3. Job Serializationβ
Keep job payloads small:
# Good - store reference
@app.task
def send_invoice(invoice_id: str):
invoice = Invoice.get(invoice_id) # Fetch from DB
return client.documents.send(invoice.xml)
# Avoid - large payloads in queue
@app.task
def send_invoice(invoice_xml: str): # Could be 100KB+
return client.documents.send(invoice_xml)
4. Correlation IDsβ
Track jobs across systems:
import uuid
def queue_invoice(invoice):
correlation_id = str(uuid.uuid4())
send_invoice.apply_async(
args=[invoice.xml],
kwargs={'correlation_id': correlation_id},
task_id=correlation_id # Use same ID for tracing
)
return correlation_id