Skip to content

feat(gitea): add edge caching for tickets API to reduce Gitea traffic#629

Open
dadofsambonzuki wants to merge 4 commits intomainfrom
fix/gitea-edge-caching
Open

feat(gitea): add edge caching for tickets API to reduce Gitea traffic#629
dadofsambonzuki wants to merge 4 commits intomainfrom
fix/gitea-edge-caching

Conversation

@dadofsambonzuki
Copy link
Member

@dadofsambonzuki dadofsambonzuki commented Jan 9, 2026

User description

Summary

  • Add new /api/tickets endpoint with 24-hour edge caching at Netlify CDN
  • Replace ineffective in-memory cache (didn't work with serverless) with shared edge cache
  • Add proper pagination to fetch all 1000+ open issues from Gitea
  • Update /tickets, /country/*/maintain, and /community/*/maintain pages to use the cached endpoint

Problem

The previous in-memory cache was incompatible with Netlify's serverless architecture:

  • Each function instance had its own isolated cache
  • Cold starts meant empty cache
  • Cache invalidation only affected the instance that created the issue

This resulted in potentially hundreds of Gitea API calls per day.

Solution

Single cached API endpoint at /api/tickets:

  • Cache-Control: public, max-age=86400, stale-while-revalidate=86400 (24hr cache)
  • Proper pagination to fetch all issues (previous code only got first page)
  • All pages fetch from this endpoint, filtering by label as needed

Expected Impact

  • Gitea API calls reduced from hundreds/day to ~1/day
  • Faster page loads (edge cache hits)
  • All 1000+ issues now accessible (pagination fixed)

See GITEA_CACHING_ANALYSIS.md for full analysis.


PR Type

Enhancement


Description

  • Replace in-memory cache with 24-hour edge cache at Netlify CDN

  • Add new /api/tickets endpoint with proper pagination support

  • Update ticket pages to fetch from centralized cached endpoint

  • Remove client-side cache invalidation logic from issue creation


Diagram Walkthrough

flowchart LR
  A["Gitea API"] -->|"Paginated fetch all issues"| B["GET /api/tickets"]
  B -->|"24hr edge cache"| C["Netlify CDN"]
  C -->|"Cached response"| D["Ticket Pages"]
  D -->|"Filter by label"| E["Rendered Content"]
Loading

File Walkthrough

Relevant files
Refactoring
gitea.ts
Remove in-memory caching logic from gitea module                 

src/lib/gitea.ts

  • Remove in-memory cache implementation and TTL logic
  • Delete syncIssuesFromGitea() and getIssues() functions
  • Remove cache invalidation from createIssueWithLabels()
  • Keep only label-fetching and issue creation utilities
+1/-96   
+page.server.ts
Update tickets page to use cached API endpoint                     

src/routes/tickets/+page.server.ts

  • Replace getIssues() call with fetch to /api/tickets endpoint
  • Add TicketsResponse type definition for API response
  • Simplify data handling by using centralized cached endpoint
  • Maintain same return structure for page component
+13/-5   
+page.server.ts
Update country pages to use cached tickets endpoint           

src/routes/country/[area]/[section]/+page.server.ts

  • Replace getIssues() with fetch to /api/tickets endpoint
  • Add filterIssuesByLabel() helper function for client-side filtering
  • Add TicketsResponse type definition for API response
  • Filter issues by area label after fetching from cache
+24/-5   
+page.server.ts
Update community pages to use cached tickets endpoint       

src/routes/community/[area]/[section]/+page.server.ts

  • Replace getIssues() with fetch to /api/tickets endpoint
  • Add filterIssuesByLabel() helper function for client-side filtering
  • Add TicketsResponse type definition for API response
  • Filter issues by area label after fetching from cache
+24/-5   
Enhancement
+server.ts
New edge-cached tickets API endpoint with pagination         

src/routes/api/tickets/+server.ts

  • Create new API endpoint for fetching all Gitea issues
  • Implement pagination to fetch all 1000+ open issues
  • Add 24-hour edge cache with stale-while-revalidate header
  • Return issues and total count as JSON response
+68/-0   

Replace ineffective in-memory cache with 24-hour edge cache at Netlify CDN.
All ticket pages now fetch from a single /api/tickets endpoint with proper
pagination support for 1000+ issues.

🤖 Generated with [opencode](https://opencode.ai)
@netlify
Copy link

netlify bot commented Jan 9, 2026

Deploy Preview for btcmap ready!

Name Link
🔨 Latest commit e598ed9
🔍 Latest deploy log https://app.netlify.com/projects/btcmap/deploys/6961383c0acbb90008358463
😎 Deploy Preview https://deploy-preview-629--btcmap.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 84 (🔴 down 9 from production)
Accessibility: 97 (no change from production)
Best Practices: 92 (🔴 down 8 from production)
SEO: 100 (no change from production)
PWA: 90 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@qodo-code-review
Copy link
Contributor

qodo-code-review bot commented Jan 9, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
Sensitive data exposure

Description: The new public /api/tickets endpoint returns full issue data and is explicitly edge-cached
(Cache-Control: public...), which could expose and widely distribute sensitive information
if any issues/fields are not intended for unauthenticated public consumption (e.g.,
internal notes in titles/bodies/labels/assignees or if the upstream repo visibility
changes).
+server.ts [55-67]

Referred Code
export const GET: RequestHandler = async ({ setHeaders }) => {
	// Cache at edge for 24 hours, serve stale for another 24 hours while revalidating
	setHeaders({
		'Cache-Control': 'public, max-age=86400, stale-while-revalidate=86400'
	});

	try {
		const issues = await fetchAllIssuesFromGitea();
		return json({ issues, totalCount: issues.length });
	} catch (error) {
		console.error('Failed to fetch issues from Gitea:', error);
		return json({ issues: [], totalCount: 0, error: 'Failed to fetch issues' }, { status: 500 });
	}
Resource exhaustion

Description: The pagination loop fetches pages until response.data.length < limit with no hard cap on
page or total items, so a misbehaving upstream API (or unexpectedly large/ever-growing
dataset) can cause long-running requests and excessive outbound calls (resource
exhaustion) on cache misses or when CDN caching is bypassed.
+server.ts [24-50]

Referred Code
while (true) {
	const response = await axios.get(
		`${env.GITEA_API_URL}/api/v1/repos/teambtcmap/btcmap-data/issues?state=open&limit=${limit}&page=${page}`,
		{ headers }
	);

	const issues = response.data.map((issue: GiteaIssue) => ({
		id: issue.id,
		number: issue.number,
		title: issue.title,
		created_at: issue.created_at,
		html_url: issue.html_url,
		labels: issue.labels,
		user: {
			login: issue.user.login,
			avatar_url: issue.user.avatar_url,
			html_url: issue.user.html_url
		},
		comments: issue.comments,
		assignees: issue.assignees
	}));


 ... (clipped 6 lines)
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Missing safeguards: The new paginated fetch loop has no maximum page/timeout guard and response errors
(including non-2xx/invalid JSON) are not checked before parsing, risking hangs and poor
failure behavior.

Referred Code
while (true) {
	const response = await axios.get(
		`${env.GITEA_API_URL}/api/v1/repos/teambtcmap/btcmap-data/issues?state=open&limit=${limit}&page=${page}`,
		{ headers }
	);

	const issues = response.data.map((issue: GiteaIssue) => ({
		id: issue.id,
		number: issue.number,
		title: issue.title,
		created_at: issue.created_at,
		html_url: issue.html_url,
		labels: issue.labels,
		user: {
			login: issue.user.login,
			avatar_url: issue.user.avatar_url,
			html_url: issue.user.html_url
		},
		comments: issue.comments,
		assignees: issue.assignees
	}));


 ... (clipped 22 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status:
Token leak in logs: Logging the raw Axios error object can leak sensitive data (e.g., Authorization header
containing env.GITEA_API_KEY) via error.config.headers in logs.

Referred Code
} catch (error) {
	console.error('Failed to fetch issues from Gitea:', error);
	return json({ issues: [], totalCount: 0, error: 'Failed to fetch issues' }, { status: 500 });

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status:
Error exposure risk: Although the endpoint returns a generic user-facing error string, the raw logged error
object may include internal request configuration details depending on runtime/log
aggregation behavior.

Referred Code
} catch (error) {
	console.error('Failed to fetch issues from Gitea:', error);
	return json({ issues: [], totalCount: 0, error: 'Failed to fetch issues' }, { status: 500 });

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Public data exposure: The new /api/tickets endpoint appears to expose all open issues without any access
control, which may be intended for public issues but requires confirmation against
security requirements.

Referred Code
export const GET: RequestHandler = async ({ setHeaders }) => {
	// Cache at edge for 24 hours, serve stale for another 24 hours while revalidating
	setHeaders({
		'Cache-Control': 'public, max-age=86400, stale-while-revalidate=86400'
	});

	try {
		const issues = await fetchAllIssuesFromGitea();
		return json({ issues, totalCount: issues.length });

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review

This comment was marked as resolved.

Gitea's API may cap the limit parameter at 50, causing pagination to break
when requesting 100. Reduced to 50 to ensure proper pagination through all
open issues.

🤖 Generated with [opencode](https://opencode.ai)
The edge cache captured the initial broken response with only 50 issues.
Adding ?v=2 creates a new cache key to bypass the stale cached response.

🤖 Generated with [opencode](https://opencode.ai)
axiosRetry(axios, { retries: 3, retryDelay: axiosRetry.exponentialDelay });

export const load: PageServerLoad = async ({ params }) => {
type TicketsResponse = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already defined above, can we move this to types or gitea.ts?

error?: string;
};

function filterIssuesByLabel(issues: GiteaIssue[], labelName: string): GiteaIssue[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and below also duplicates above. Maybe the AI has an idea how to DRY this reasonabale?

import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
type TicketsResponse = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the ineffective in-memory caching (incompatible with serverless architecture) with a centralized edge-cached API endpoint at /api/tickets. The changes reduce Gitea API traffic from potentially hundreds of calls per day to approximately one call per day through a 24-hour CDN cache.

  • Adds new /api/tickets endpoint with proper pagination to fetch all 1000+ open Gitea issues
  • Removes in-memory cache from gitea.ts module
  • Updates tickets, country, and community pages to fetch from the cached endpoint with client-side label filtering

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/routes/api/tickets/+server.ts New API endpoint with 24-hour edge caching and pagination support for fetching all Gitea issues
src/lib/gitea.ts Removes in-memory caching logic and deleted getIssues() function; retains label management and issue creation
src/routes/tickets/+page.server.ts Updates to fetch from cached /api/tickets endpoint instead of calling getIssues() directly
src/routes/country/[area]/[section]/+page.server.ts Fetches from cached endpoint and adds client-side label filtering for area-specific tickets
src/routes/community/[area]/[section]/+page.server.ts Fetches from cached endpoint and adds client-side label filtering for area-specific tickets

export const load: PageServerLoad = async ({ fetch }) => {
try {
const { issues, totalCount } = await getIssues();
const response = await fetch('/api/tickets');
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch call doesn't check the response status before calling json(). If the API returns a non-200 status (e.g., 500 error), this will still attempt to parse JSON and may not properly propagate the error. Consider checking response.ok before parsing.

Suggested change
const response = await fetch('/api/tickets');
const response = await fetch('/api/tickets');
if (!response.ok) {
throw new Error(`Failed to fetch tickets: ${response.status} ${response.statusText}`);
}

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +8
type TicketsResponse = {
issues: GiteaIssue[];
totalCount: number;
error?: string;
};
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TicketsResponse type is duplicated across three files (tickets page, country page, and community page). This violates the DRY principle and makes maintenance harder. Consider moving this type to a shared location like src/lib/types.ts where GiteaIssue is already defined.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +40
const ticketsResponse = await fetch('/api/tickets');
const ticketsData: TicketsResponse = await ticketsResponse.json();
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch call doesn't check the response status before calling json(). If the API returns a non-200 status (e.g., 500 error), this will still attempt to parse JSON and may not properly propagate the error. Consider checking response.ok before parsing.

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +14
type TicketsResponse = {
issues: GiteaIssue[];
totalCount: number;
error?: string;
};
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TicketsResponse type is duplicated across multiple files. This violates the DRY principle and makes maintenance harder. Consider moving this type to a shared location like src/lib/types.ts where GiteaIssue is already defined.

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +14
type TicketsResponse = {
issues: GiteaIssue[];
totalCount: number;
error?: string;
};
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TicketsResponse type is duplicated across multiple files. This violates the DRY principle and makes maintenance harder. Consider moving this type to a shared location like src/lib/types.ts where GiteaIssue is already defined.

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +7
import axios from 'axios';
import { env } from '$env/dynamic/private';

import type { RequestHandler } from './$types';
import type { GiteaIssue } from '$lib/types';

Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing axios-retry configuration for resilience. Other API endpoints in this codebase use axiosRetry with exponential backoff for better reliability. Consider adding similar retry logic here to handle transient Gitea API failures, especially since this endpoint is cached for 24 hours.

Suggested change
import axios from 'axios';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
import type { GiteaIssue } from '$lib/types';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
import type { GiteaIssue } from '$lib/types';
axiosRetry(axios, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error) =>
axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error)
});

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +20
function filterIssuesByLabel(issues: GiteaIssue[], labelName: string): GiteaIssue[] {
return issues.filter((issue) =>
issue.labels.some((label) => label.name.toLowerCase() === labelName.toLowerCase())
);
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filterIssuesByLabel function is duplicated in both country and community page server files. This duplicated logic should be extracted to a shared utility module to avoid maintenance issues and ensure consistent behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +38
const ticketsResponse = await fetch('/api/tickets');
const ticketsData: TicketsResponse = await ticketsResponse.json();
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch call doesn't check the response status before calling json(). If the API returns a non-200 status (e.g., 500 error), this will still attempt to parse JSON and may not properly propagate the error. Consider checking response.ok before parsing.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +52
while (true) {
const response = await axios.get(
`${env.GITEA_API_URL}/api/v1/repos/teambtcmap/btcmap-data/issues?state=open&limit=${limit}&page=${page}`,
{ headers }
);

const issues = response.data.map((issue: GiteaIssue) => ({
id: issue.id,
number: issue.number,
title: issue.title,
created_at: issue.created_at,
html_url: issue.html_url,
labels: issue.labels,
user: {
login: issue.user.login,
avatar_url: issue.user.avatar_url,
html_url: issue.user.html_url
},
comments: issue.comments,
assignees: issue.assignees
}));

allIssues.push(...issues);

// If we got fewer than requested, we've reached the end
if (response.data.length < limit) break;
page++;
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pagination loop lacks safeguards against infinite loops. If the API response is malformed or if the limit check fails, this could loop indefinitely. Consider adding a maximum page limit (e.g., 100 pages) to prevent potential runaway execution in serverless environments.

Copilot uses AI. Check for mistakes.
Comment on lines 15 to 18
return {
tickets: issues,
totalTickets: totalCount
tickets: data.issues,
totalTickets: data.totalCount
};
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error field from the API response is not being checked or propagated. When the API returns a 500 status with an error message in the response body, this code will successfully parse the JSON and return empty arrays without surfacing the actual error message to the user. Consider checking data.error and returning it if present.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants